Add canonical ID pipeline and fix UUID consistency for CloudKit sync

- 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>
This commit is contained in:
Trey t
2026-01-09 10:30:09 -06:00
parent 1ee47df53e
commit 7efcea7bd4
31 changed files with 128868 additions and 282 deletions

View File

@@ -210,21 +210,18 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStadiums = try await cloudKitService.fetchStadiums()
// 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 remoteStadium in remoteStadiums {
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
// In future, could add lastModified filtering on CloudKit query
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium(
remoteStadium,
canonicalId: canonicalId,
syncStadium.stadium,
canonicalId: syncStadium.canonicalId,
context: context
)
@@ -243,23 +240,23 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch teams for all sports
var allTeams: [Team] = []
// Use sync method that returns canonical IDs directly from CloudKit
var allSyncTeams: [CloudKitService.SyncTeam] = []
for sport in Sport.allCases {
let teams = try await cloudKitService.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
let syncTeams = try await cloudKitService.fetchTeamsForSync(for: sport)
allSyncTeams.append(contentsOf: syncTeams)
}
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteTeam in allTeams {
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
for syncTeam in allSyncTeams {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeTeam(
remoteTeam,
canonicalId: canonicalId,
syncTeam.team,
canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
context: context
)
@@ -278,11 +275,11 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch games for the next 6 months from all sports
// 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 remoteGames = try await cloudKitService.fetchGames(
let syncGames = try await cloudKitService.fetchGamesForSync(
sports: Set(Sport.allCases),
startDate: startDate,
endDate: endDate
@@ -292,10 +289,14 @@ actor CanonicalSyncService {
var skippedIncompatible = 0
var skippedOlder = 0
for remoteGame in remoteGames {
for syncGame in syncGames {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeGame(
remoteGame,
canonicalId: remoteGame.id.uuidString,
syncGame.game,
canonicalId: syncGame.canonicalId,
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
stadiumCanonicalId: syncGame.stadiumCanonicalId,
context: context
)
@@ -427,10 +428,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalStadium(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
@@ -453,6 +454,7 @@ actor CanonicalSyncService {
private func mergeTeam(
_ remote: Team,
canonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>(
@@ -460,13 +462,7 @@ actor CanonicalSyncService {
)
let existing = try context.fetch(descriptor).first
// Find stadium canonical ID
let remoteStadiumId = remote.stadiumId
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let stadium = try context.fetch(stadiumDescriptor).first
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
if let existing = existing {
// Preserve user fields
@@ -491,9 +487,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalTeam(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
@@ -515,6 +512,9 @@ actor CanonicalSyncService {
private func mergeGame(
_ remote: Game,
canonicalId: String,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>(
@@ -522,28 +522,7 @@ actor CanonicalSyncService {
)
let existing = try context.fetch(descriptor).first
// Look up canonical IDs for teams and stadium
let remoteHomeTeamId = remote.homeTeamId
let remoteAwayTeamId = remote.awayTeamId
let remoteStadiumId = remote.stadiumId
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
)
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
)
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let homeTeam = try context.fetch(homeTeamDescriptor).first
let awayTeam = try context.fetch(awayTeamDescriptor).first
let stadium = try context.fetch(stadiumDescriptor).first
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
if let existing = existing {
// Preserve user fields
@@ -568,9 +547,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalGame(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,