docs: add canonical ID refactor design
Design to eliminate UUID layer and use canonical IDs as the single stadium/team/game identifier throughout the client. Key changes: - Domain models use String IDs (canonical IDs) - Remove UUID mapping dictionaries from DataProvider - Simplify AchievementEngine (delete resolution helper) - StadiumVisit uses single stadiumId field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
216
docs/plans/2026-01-11-canonical-id-refactor-design.md
Normal file
216
docs/plans/2026-01-11-canonical-id-refactor-design.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Canonical ID Refactor Design
|
||||
|
||||
**Date:** 2026-01-11
|
||||
**Status:** Approved
|
||||
**Goal:** Eliminate UUID layer, use canonical IDs as the single identifier throughout the client
|
||||
|
||||
## Problem
|
||||
|
||||
The codebase has 4 different stadium ID formats causing bugs:
|
||||
1. Canonical IDs: `"stadium_mlb_fenway_park"` (stable identity)
|
||||
2. Symbolic IDs: `"stadium_mlb_bos"` (team-based, achievements)
|
||||
3. UUIDs: `UUID` type (runtime lookups)
|
||||
4. UUID strings: `"550e8400-..."` (stored in visits)
|
||||
|
||||
This led to the Green Monster/Ivy League achievement bug where visits stored UUID strings but achievements expected canonical IDs.
|
||||
|
||||
## Solution
|
||||
|
||||
Use canonical IDs (`String`) as the single identifier everywhere on the client. CloudKit keeps reference integrity at the sync layer.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Domain Models
|
||||
|
||||
```swift
|
||||
struct Stadium: Identifiable, Codable, Hashable {
|
||||
let id: String // "stadium_mlb_fenway_park" - THE identity
|
||||
let name: String
|
||||
let city: String
|
||||
let state: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let capacity: Int
|
||||
let sport: Sport
|
||||
let yearOpened: Int?
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
struct Team: Identifiable, Codable, Hashable {
|
||||
let id: String // "team_mlb_bos"
|
||||
let name: String
|
||||
let abbreviation: String
|
||||
let sport: Sport
|
||||
let city: String
|
||||
let stadiumId: String // FK: "stadium_mlb_fenway_park"
|
||||
// ...
|
||||
}
|
||||
|
||||
struct Game: Identifiable, Codable, Hashable {
|
||||
let id: String // "game_mlb_2026_bos_nyy_0401"
|
||||
let homeTeamId: String // "team_mlb_bos"
|
||||
let awayTeamId: String // "team_mlb_nyy"
|
||||
let stadiumId: String // "stadium_mlb_fenway_park"
|
||||
let dateTime: Date
|
||||
let sport: Sport
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Visit Tracking
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class StadiumVisit {
|
||||
@Attribute(.unique) var id: UUID // Visit's own identity
|
||||
|
||||
var stadiumId: String // "stadium_mlb_fenway_park"
|
||||
var stadiumNameAtVisit: String // Frozen: "Fenway Park"
|
||||
|
||||
var visitDate: Date
|
||||
var sport: String
|
||||
var visitTypeRaw: String
|
||||
|
||||
var gameId: String?
|
||||
var homeTeamId: String?
|
||||
var awayTeamId: String?
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Data Provider
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class AppDataProvider: ObservableObject {
|
||||
@Published private(set) var teams: [Team] = []
|
||||
@Published private(set) var stadiums: [Stadium] = []
|
||||
|
||||
// Lookup dictionaries - keyed by canonical ID (String)
|
||||
private var teamsById: [String: Team] = [:]
|
||||
private var stadiumsById: [String: Stadium] = [:]
|
||||
|
||||
// REMOVED: UUID mapping dictionaries
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Achievement Engine
|
||||
|
||||
```swift
|
||||
// Achievement definitions use canonical IDs directly
|
||||
AchievementDefinition(
|
||||
id: "special_fenway",
|
||||
name: "Green Monster",
|
||||
requirement: .specificStadium("stadium_mlb_fenway_park")
|
||||
)
|
||||
|
||||
// Checking is direct comparison
|
||||
case .specificStadium(let stadiumId):
|
||||
return visitedStadiumIds.contains(stadiumId)
|
||||
|
||||
// REMOVED: resolveSymbolicStadiumId() helper
|
||||
```
|
||||
|
||||
### 5. CloudKit Layer (Unchanged)
|
||||
|
||||
SwiftData models keep their structure. CloudKit references ensure data integrity at sync time. The `toDomain()` conversion simplifies:
|
||||
|
||||
```swift
|
||||
extension CanonicalStadium {
|
||||
func toDomain() -> Stadium {
|
||||
Stadium(
|
||||
id: canonicalId, // Direct pass-through
|
||||
name: name,
|
||||
// ...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanonicalTeam {
|
||||
func toDomain() -> Team { // No more stadiumUUID parameter
|
||||
Team(
|
||||
id: canonicalId,
|
||||
stadiumId: stadiumCanonicalId,
|
||||
// ...
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
Since we're in development, this is a clean break.
|
||||
|
||||
### Files to Modify (in order)
|
||||
|
||||
| Phase | Files | Changes |
|
||||
|-------|-------|---------|
|
||||
| 1. Domain Models | `Stadium.swift`, `Team.swift`, `Game.swift` | `id: UUID` → `id: String` |
|
||||
| 2. SwiftData Converters | `CanonicalModels.swift` | Simplify `toDomain()` methods |
|
||||
| 3. Data Provider | `DataProvider.swift` | Remove UUID mapping dicts |
|
||||
| 4. Visit Model | `StadiumProgress.swift` | Remove `stadiumUUID`, simplify |
|
||||
| 5. Achievement Engine | `AchievementEngine.swift` | Delete resolution helper |
|
||||
| 6. Planning Engine | `GameDAGRouter.swift`, planners | Update dictionary key types |
|
||||
| 7. View Models | Various | Update UUID references |
|
||||
| 8. Tests | All test files | Update fixtures |
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
# Delete local SwiftData store
|
||||
rm -rf ~/Library/Developer/CoreSimulator/.../SportsTime/Library/Application\ Support/default.store
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Compile-time Safety
|
||||
|
||||
```swift
|
||||
// Wrong type won't compile
|
||||
let stadium = stadiums[someUUID] // Error: String key required
|
||||
```
|
||||
|
||||
### Key Test Cases
|
||||
|
||||
```swift
|
||||
// 1. ID format validation
|
||||
func test_stadiumId_followsCanonicalFormat() {
|
||||
for stadium in AppDataProvider.shared.stadiums {
|
||||
XCTAssertTrue(stadium.id.hasPrefix("stadium_"))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. FK integrity
|
||||
func test_allTeamStadiumIds_existInStadiums() {
|
||||
let stadiumIds = Set(dataProvider.stadiums.map { $0.id })
|
||||
for team in dataProvider.teams {
|
||||
XCTAssertTrue(stadiumIds.contains(team.stadiumId))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Achievement matching
|
||||
func test_specificStadiumAchievement_matchesVisit() {
|
||||
let visit = StadiumVisit(stadiumId: "stadium_mlb_fenway_park", ...)
|
||||
let achievement = AchievementRegistry.achievement(byId: "special_fenway")!
|
||||
|
||||
if case .specificStadium(let requiredId) = achievement.requirement {
|
||||
XCTAssertEqual(visit.stadiumId, requiredId)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Round-trip consistency
|
||||
func test_canonicalModel_toDomain_preservesId() {
|
||||
let canonical = CanonicalStadium(canonicalId: "stadium_mlb_fenway_park", ...)
|
||||
let domain = canonical.toDomain()
|
||||
XCTAssertEqual(canonical.canonicalId, domain.id)
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Single source of truth** - canonical ID is the only identifier
|
||||
2. **Human-readable** - see `stadium_mlb_fenway_park` in logs, not UUIDs
|
||||
3. **No mapping layer** - eliminate conversion dictionaries
|
||||
4. **Bug category eliminated** - ID format mismatches become impossible
|
||||
5. **Stable across renames** - stadium renames don't break visits
|
||||
6. **Compiler catches mistakes** - String vs UUID type mismatch won't compile
|
||||
Reference in New Issue
Block a user