- 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>
687 lines
23 KiB
Swift
687 lines
23 KiB
Swift
//
|
|
// CanonicalSyncService.swift
|
|
// SportsTime
|
|
//
|
|
// Orchestrates syncing canonical data from CloudKit into SwiftData.
|
|
// Uses date-based delta sync for public database efficiency.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import CloudKit
|
|
|
|
actor CanonicalSyncService {
|
|
|
|
// MARK: - Errors
|
|
|
|
enum SyncError: Error, LocalizedError {
|
|
case cloudKitUnavailable
|
|
case syncAlreadyInProgress
|
|
case saveFailed(Error)
|
|
case schemaVersionTooNew(Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .cloudKitUnavailable:
|
|
return "CloudKit is not available. Check your internet connection and iCloud settings."
|
|
case .syncAlreadyInProgress:
|
|
return "A sync operation is already in progress."
|
|
case .saveFailed(let error):
|
|
return "Failed to save synced data: \(error.localizedDescription)"
|
|
case .schemaVersionTooNew(let version):
|
|
return "Data requires app version supporting schema \(version). Please update the app."
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Result
|
|
|
|
struct SyncResult {
|
|
let stadiumsUpdated: Int
|
|
let teamsUpdated: Int
|
|
let gamesUpdated: Int
|
|
let leagueStructuresUpdated: Int
|
|
let teamAliasesUpdated: Int
|
|
let stadiumAliasesUpdated: Int
|
|
let skippedIncompatible: Int
|
|
let skippedOlder: Int
|
|
let duration: TimeInterval
|
|
|
|
var totalUpdated: Int {
|
|
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
|
|
}
|
|
|
|
var isEmpty: Bool { totalUpdated == 0 }
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
private let cloudKitService: CloudKitService
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(cloudKitService: CloudKitService = .shared) {
|
|
self.cloudKitService = cloudKitService
|
|
}
|
|
|
|
// MARK: - Public Sync Methods
|
|
|
|
/// Perform a full sync of all canonical data types.
|
|
/// This is the main entry point for background sync.
|
|
@MainActor
|
|
func syncAll(context: ModelContext) async throws -> SyncResult {
|
|
let startTime = Date()
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
// Prevent concurrent syncs
|
|
guard !syncState.syncInProgress else {
|
|
throw SyncError.syncAlreadyInProgress
|
|
}
|
|
|
|
// Check if sync is enabled
|
|
guard syncState.syncEnabled else {
|
|
return SyncResult(
|
|
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
|
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
|
skippedIncompatible: 0, skippedOlder: 0,
|
|
duration: 0
|
|
)
|
|
}
|
|
|
|
// Check CloudKit availability
|
|
guard await cloudKitService.isAvailable() else {
|
|
throw SyncError.cloudKitUnavailable
|
|
}
|
|
|
|
// Mark sync in progress
|
|
syncState.syncInProgress = true
|
|
syncState.lastSyncAttempt = Date()
|
|
|
|
var totalStadiums = 0
|
|
var totalTeams = 0
|
|
var totalGames = 0
|
|
var totalLeagueStructures = 0
|
|
var totalTeamAliases = 0
|
|
var totalStadiumAliases = 0
|
|
var totalSkippedIncompatible = 0
|
|
var totalSkippedOlder = 0
|
|
|
|
do {
|
|
// Sync in dependency order
|
|
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalStadiums = stadiums
|
|
totalSkippedIncompatible += skipIncompat1
|
|
totalSkippedOlder += skipOlder1
|
|
|
|
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalLeagueStructures = leagueStructures
|
|
totalSkippedIncompatible += skipIncompat2
|
|
totalSkippedOlder += skipOlder2
|
|
|
|
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeams = teams
|
|
totalSkippedIncompatible += skipIncompat3
|
|
totalSkippedOlder += skipOlder3
|
|
|
|
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeamAliases = teamAliases
|
|
totalSkippedIncompatible += skipIncompat4
|
|
totalSkippedOlder += skipOlder4
|
|
|
|
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalStadiumAliases = stadiumAliases
|
|
totalSkippedIncompatible += skipIncompat5
|
|
totalSkippedOlder += skipOlder5
|
|
|
|
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalGames = games
|
|
totalSkippedIncompatible += skipIncompat6
|
|
totalSkippedOlder += skipOlder6
|
|
|
|
// Mark sync successful
|
|
syncState.syncInProgress = false
|
|
syncState.lastSuccessfulSync = Date()
|
|
syncState.lastSyncError = nil
|
|
syncState.consecutiveFailures = 0
|
|
|
|
try context.save()
|
|
|
|
} catch {
|
|
// Mark sync failed
|
|
syncState.syncInProgress = false
|
|
syncState.lastSyncError = error.localizedDescription
|
|
syncState.consecutiveFailures += 1
|
|
|
|
// Pause sync after too many failures
|
|
if syncState.consecutiveFailures >= 5 {
|
|
syncState.syncEnabled = false
|
|
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
|
}
|
|
|
|
try? context.save()
|
|
throw error
|
|
}
|
|
|
|
return SyncResult(
|
|
stadiumsUpdated: totalStadiums,
|
|
teamsUpdated: totalTeams,
|
|
gamesUpdated: totalGames,
|
|
leagueStructuresUpdated: totalLeagueStructures,
|
|
teamAliasesUpdated: totalTeamAliases,
|
|
stadiumAliasesUpdated: totalStadiumAliases,
|
|
skippedIncompatible: totalSkippedIncompatible,
|
|
skippedOlder: totalSkippedOlder,
|
|
duration: Date().timeIntervalSince(startTime)
|
|
)
|
|
}
|
|
|
|
/// Re-enable sync after it was paused due to failures.
|
|
@MainActor
|
|
func resumeSync(context: ModelContext) {
|
|
let syncState = SyncState.current(in: context)
|
|
syncState.syncEnabled = true
|
|
syncState.syncPausedReason = nil
|
|
syncState.consecutiveFailures = 0
|
|
try? context.save()
|
|
}
|
|
|
|
// MARK: - Individual Sync Methods
|
|
|
|
@MainActor
|
|
private func syncStadiums(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Use sync method that returns canonical IDs directly from CloudKit
|
|
let syncStadiums = try await cloudKitService.fetchStadiumsForSync()
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncStadium in syncStadiums {
|
|
// Use canonical ID directly from CloudKit - no UUID-based generation!
|
|
let result = try mergeStadium(
|
|
syncStadium.stadium,
|
|
canonicalId: syncStadium.canonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeams(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Use sync method that returns canonical IDs directly from CloudKit
|
|
var allSyncTeams: [CloudKitService.SyncTeam] = []
|
|
for sport in Sport.allCases {
|
|
let syncTeams = try await cloudKitService.fetchTeamsForSync(for: sport)
|
|
allSyncTeams.append(contentsOf: syncTeams)
|
|
}
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncTeam in allSyncTeams {
|
|
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
|
let result = try mergeTeam(
|
|
syncTeam.team,
|
|
canonicalId: syncTeam.canonicalId,
|
|
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncGames(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Use sync method that returns canonical IDs directly from CloudKit
|
|
let startDate = lastSync ?? Date()
|
|
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
|
|
|
|
let syncGames = try await cloudKitService.fetchGamesForSync(
|
|
sports: Set(Sport.allCases),
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncGame in syncGames {
|
|
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
|
let result = try mergeGame(
|
|
syncGame.game,
|
|
canonicalId: syncGame.canonicalId,
|
|
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
|
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
|
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncLeagueStructure(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteStructure in remoteStructures {
|
|
let result = try mergeLeagueStructure(remoteStructure, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeamAliases(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteAlias in remoteAliases {
|
|
let result = try mergeTeamAlias(remoteAlias, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncStadiumAliases(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteAlias in remoteAliases {
|
|
let result = try mergeStadiumAlias(remoteAlias, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
// MARK: - Merge Logic
|
|
|
|
private enum MergeResult {
|
|
case applied
|
|
case skippedIncompatible
|
|
case skippedOlder
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeStadium(
|
|
_ remote: Stadium,
|
|
canonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Look up existing
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedNickname = existing.userNickname
|
|
let savedNotes = existing.userNotes
|
|
let savedFavorite = existing.isFavorite
|
|
|
|
// Update system fields
|
|
existing.name = remote.name
|
|
existing.city = remote.city
|
|
existing.state = remote.state
|
|
existing.latitude = remote.latitude
|
|
existing.longitude = remote.longitude
|
|
existing.capacity = remote.capacity
|
|
existing.yearOpened = remote.yearOpened
|
|
existing.imageURL = remote.imageURL?.absoluteString
|
|
existing.sport = remote.sport.rawValue
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.userNotes = savedNotes
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalStadium(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
city: remote.city,
|
|
state: remote.state,
|
|
latitude: remote.latitude,
|
|
longitude: remote.longitude,
|
|
capacity: remote.capacity,
|
|
yearOpened: remote.yearOpened,
|
|
imageURL: remote.imageURL?.absoluteString,
|
|
sport: remote.sport.rawValue
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeam(
|
|
_ remote: Team,
|
|
canonicalId: String,
|
|
stadiumCanonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedNickname = existing.userNickname
|
|
let savedFavorite = existing.isFavorite
|
|
|
|
// Update system fields
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.sport = remote.sport.rawValue
|
|
existing.city = remote.city
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.logoURL = remote.logoURL?.absoluteString
|
|
existing.primaryColor = remote.primaryColor
|
|
existing.secondaryColor = remote.secondaryColor
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalTeam(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
abbreviation: remote.abbreviation,
|
|
sport: remote.sport.rawValue,
|
|
city: remote.city,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
logoURL: remote.logoURL?.absoluteString,
|
|
primaryColor: remote.primaryColor,
|
|
secondaryColor: remote.secondaryColor
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeGame(
|
|
_ remote: Game,
|
|
canonicalId: String,
|
|
homeTeamCanonicalId: String,
|
|
awayTeamCanonicalId: String,
|
|
stadiumCanonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedAttending = existing.userAttending
|
|
let savedNotes = existing.userNotes
|
|
|
|
// Update system fields
|
|
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
|
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.dateTime = remote.dateTime
|
|
existing.sport = remote.sport.rawValue
|
|
existing.season = remote.season
|
|
existing.isPlayoff = remote.isPlayoff
|
|
existing.broadcastInfo = remote.broadcastInfo
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userAttending = savedAttending
|
|
existing.userNotes = savedNotes
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalGame(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
dateTime: remote.dateTime,
|
|
sport: remote.sport.rawValue,
|
|
season: remote.season,
|
|
isPlayoff: remote.isPlayoff,
|
|
broadcastInfo: remote.broadcastInfo
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeLeagueStructure(
|
|
_ remote: LeagueStructureModel,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
|
predicate: #Predicate { $0.id == remoteId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on LeagueStructure)
|
|
existing.sport = remote.sport
|
|
existing.structureTypeRaw = remote.structureTypeRaw
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.parentId = remote.parentId
|
|
existing.displayOrder = remote.displayOrder
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeamAlias(
|
|
_ remote: TeamAlias,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<TeamAlias>(
|
|
predicate: #Predicate { $0.id == remoteId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on TeamAlias)
|
|
existing.teamCanonicalId = remote.teamCanonicalId
|
|
existing.aliasTypeRaw = remote.aliasTypeRaw
|
|
existing.aliasValue = remote.aliasValue
|
|
existing.validFrom = remote.validFrom
|
|
existing.validUntil = remote.validUntil
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeStadiumAlias(
|
|
_ remote: StadiumAlias,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteAliasName = remote.aliasName
|
|
let descriptor = FetchDescriptor<StadiumAlias>(
|
|
predicate: #Predicate { $0.aliasName == remoteAliasName }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on StadiumAlias)
|
|
existing.stadiumCanonicalId = remote.stadiumCanonicalId
|
|
existing.validFrom = remote.validFrom
|
|
existing.validUntil = remote.validUntil
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
}
|