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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user