- CloudKit pagination: fetchAllRecords() handles >400 record batches with cursor-based pagination (400 records per page) - Cancellation support: SyncCancellationToken protocol enables graceful sync termination when background tasks expire - Per-entity progress: SyncState now tracks timestamps per entity type so interrupted syncs resume where they left off - NetworkMonitor: NWPathMonitor integration triggers sync on network restoration with 2.5s debounce to handle WiFi↔cellular flapping - wasCancelled flag in SyncResult distinguishes partial from full syncs This addresses critical data sync issues: - CloudKit queries were limited to ~400 records but bundled data has ~5000 games - Background tasks could be killed mid-sync without saving progress - App had no awareness of network state changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
582 lines
22 KiB
Swift
582 lines
22 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
|
|
|
|
/// Maximum records per CloudKit query (400 is the default limit)
|
|
private let recordsPerPage = 400
|
|
|
|
private init() {
|
|
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
|
self.publicDatabase = container.publicCloudDatabase
|
|
}
|
|
|
|
// MARK: - Pagination Helper
|
|
|
|
/// Fetches all records matching a query using cursor-based pagination.
|
|
/// Checks cancellation token between pages to allow graceful interruption.
|
|
private func fetchAllRecords(
|
|
matching query: CKQuery,
|
|
cancellationToken: SyncCancellationToken?
|
|
) async throws -> [CKRecord] {
|
|
var allRecords: [CKRecord] = []
|
|
var cursor: CKQueryOperation.Cursor?
|
|
|
|
// First page
|
|
let (firstResults, firstCursor) = try await publicDatabase.records(
|
|
matching: query,
|
|
resultsLimit: recordsPerPage
|
|
)
|
|
|
|
for result in firstResults {
|
|
if case .success(let record) = result.1 {
|
|
allRecords.append(record)
|
|
}
|
|
}
|
|
cursor = firstCursor
|
|
|
|
// Continue fetching pages while cursor exists
|
|
while let currentCursor = cursor {
|
|
// Check cancellation between pages
|
|
if cancellationToken?.isCancelled == true {
|
|
throw CancellationError()
|
|
}
|
|
|
|
let (results, nextCursor) = try await publicDatabase.records(
|
|
continuingMatchFrom: currentCursor,
|
|
resultsLimit: recordsPerPage
|
|
)
|
|
|
|
for result in results {
|
|
if case .success(let record) = result.1 {
|
|
allRecords.append(record)
|
|
}
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
|
|
return allRecords
|
|
}
|
|
|
|
// MARK: - Sync Types (include canonical IDs from CloudKit)
|
|
|
|
struct SyncStadium {
|
|
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
|
|
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 predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
|
|
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record -> SyncStadium? in
|
|
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 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 predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
|
|
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record -> SyncTeam? in
|
|
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 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 predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
|
|
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record -> SyncGame? in
|
|
let ckGame = CKGame(record: record)
|
|
|
|
guard let canonicalId = ckGame.canonicalId,
|
|
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
|
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
|
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
|
else { return nil }
|
|
|
|
guard let game = ckGame.game(
|
|
homeTeamId: homeTeamCanonicalId,
|
|
awayTeamId: awayTeamCanonicalId,
|
|
stadiumId: stadiumCanonicalId
|
|
) else { return nil }
|
|
|
|
return SyncGame(
|
|
game: game,
|
|
canonicalId: canonicalId,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId
|
|
)
|
|
}.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 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 records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record in
|
|
return CKLeagueStructure(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
/// 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 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 records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record in
|
|
return CKTeamAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
/// 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 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 records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record in
|
|
return CKStadiumAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
// 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 predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
|
|
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
|
|
|
return records.compactMap { record -> CanonicalSport? in
|
|
return CKSport(record: record).toCanonical()
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|