Add StadiumAlias CloudKit sync and offline-first data architecture

- Add CKStadiumAlias model for CloudKit record mapping
- Add fetchStadiumAliases/fetchStadiumAliasChanges to CloudKitService
- Add syncStadiumAliases to CanonicalSyncService for delta sync
- Add subscribeToStadiumAliasUpdates for push notifications
- Update cloudkit_import.py with --stadium-aliases-only option

Data Architecture Updates:
- Remove obsolete provider files (CanonicalDataProvider, CloudKitDataProvider, StubDataProvider)
- AppDataProvider now reads exclusively from SwiftData
- Add background CloudKit sync on app startup (non-blocking)
- Document data architecture in CLAUDE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-08 22:20:07 -06:00
parent 588938d2a1
commit 1ee47df53e
12 changed files with 482 additions and 780 deletions

View File

@@ -42,12 +42,13 @@ actor CanonicalSyncService {
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
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
}
var isEmpty: Bool { totalUpdated == 0 }
@@ -81,7 +82,7 @@ actor CanonicalSyncService {
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
skippedIncompatible: 0, skippedOlder: 0,
duration: 0
)
@@ -101,6 +102,7 @@ actor CanonicalSyncService {
var totalGames = 0
var totalLeagueStructures = 0
var totalTeamAliases = 0
var totalStadiumAliases = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
@@ -138,13 +140,21 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
let (games, skipIncompat5, skipOlder5) = try await syncGames(
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 += skipIncompat5
totalSkippedOlder += skipOlder5
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
// Mark sync successful
syncState.syncInProgress = false
@@ -176,6 +186,7 @@ actor CanonicalSyncService {
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime)
@@ -346,6 +357,30 @@ actor CanonicalSyncService {
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 {
@@ -631,4 +666,41 @@ actor CanonicalSyncService {
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
}
}
}