# Sync Reliability Architecture

This document describes the CloudKit-to-SwiftData synchronization system in SportsTime, including pagination, cancellation support, network monitoring, and background task integration.

## Table of Contents

- [Overview](#overview)
- [Architecture](#architecture)
- [Components](#components)
  - [CloudKitService](#cloudkitservice)
  - [CanonicalSyncService](#canonicalsyncservice)
  - [BackgroundSyncManager](#backgroundsyncmanager)
  - [NetworkMonitor](#networkmonitor)
  - [SyncCancellationToken](#synccancellationtoken)
- [Data Flow](#data-flow)
- [Sync Triggers](#sync-triggers)
- [Error Handling](#error-handling)
- [Debugging](#debugging)
- [Testing](#testing)

---

## Overview

SportsTime uses an offline-first architecture where:

1. **Bundled JSON** provides initial data on first launch
2. **SwiftData** stores all canonical data locally
3. **CloudKit** (public database) is the authoritative source for schedule updates
4. **Background sync** keeps local data fresh without user intervention

The sync system is designed to be:

- **Resilient**: Handles network failures, partial syncs, and interrupted background tasks
- **Efficient**: Uses cursor-based pagination to handle large datasets (5000+ games)
- **Non-blocking**: Never prevents the user from using the app
- **Battery-conscious**: Debounces network changes and schedules heavy work overnight

---

## Architecture

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                              Sync Triggers                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│  App Launch    │  Foreground  │  Network Restore  │  Background Task        │
│  (bootstrap)   │  (resume)    │  (NetworkMonitor) │  (BGTaskScheduler)      │
└───────┬────────┴──────┬───────┴─────────┬─────────┴───────────┬─────────────┘
        │               │                 │                     │
        └───────────────┴─────────────────┴─────────────────────┘
                                    │
                                    ▼
        ┌─────────────────────────────────────────────────────────┐
        │                  CanonicalSyncService                    │
        │                                                          │
        │  • Orchestrates sync across all entity types             │
        │  • Tracks per-entity progress in SyncState               │
        │  • Handles cancellation for graceful termination         │
        │  • Returns SyncResult with counts and status             │
        └─────────────────────────┬───────────────────────────────┘
                                  │
                                  ▼
        ┌─────────────────────────────────────────────────────────┐
        │                    CloudKitService                       │
        │                                                          │
        │  • Fetches records from CloudKit public database         │
        │  • Uses cursor-based pagination (400 records/page)       │
        │  • Supports cancellation between pages                   │
        │  • Parses CKRecord → Domain models                       │
        └─────────────────────────┬───────────────────────────────┘
                                  │
                                  ▼
        ┌─────────────────────────────────────────────────────────┐
        │                      SwiftData                           │
        │                                                          │
        │  • Canonical models: Stadium, Team, Game, etc.           │
        │  • SyncState: tracks last sync times per entity          │
        │  • Persists across app launches                          │
        └─────────────────────────────────────────────────────────┘
```

---

## Components

### CloudKitService

**File:** `SportsTime/Core/Services/CloudKitService.swift`

The CloudKitService handles all communication with CloudKit's public database. It provides type-safe fetch methods for each entity type.

#### Pagination

CloudKit queries are limited to returning approximately 400 records per request. With 5000+ games in the database, pagination is essential.

```swift
private let recordsPerPage = 400

private func fetchAllRecords(
    matching query: CKQuery,
    cancellationToken: SyncCancellationToken?
) async throws -> [CKRecord] {
    var allRecords: [CKRecord] = []
    var cursor: CKQueryOperation.Cursor?

    // Fetch first page
    let (firstResults, firstCursor) = try await publicDatabase.records(
        matching: query,
        resultsLimit: recordsPerPage
    )
    // Process results...
    cursor = firstCursor

    // Continue fetching while cursor exists
    while let currentCursor = cursor {
        // Check for cancellation between pages
        if cancellationToken?.isCancelled == true {
            throw CancellationError()
        }

        let (results, nextCursor) = try await publicDatabase.records(
            continuingMatchFrom: currentCursor,
            resultsLimit: recordsPerPage
        )
        // Process results...
        cursor = nextCursor
    }

    return allRecords
}
```

#### Fetch Methods

Each entity type has a dedicated fetch method:

| Method | Entity | Record Type |
|--------|--------|-------------|
| `fetchStadiumsForSync(since:cancellationToken:)` | Stadium | `Stadium` |
| `fetchTeamsForSync(since:cancellationToken:)` | Team | `Team` |
| `fetchGamesForSync(since:cancellationToken:)` | Game | `Game` |
| `fetchLeagueStructureChanges(since:cancellationToken:)` | LeagueStructure | `LeagueStructure` |
| `fetchTeamAliasChanges(since:cancellationToken:)` | TeamAlias | `TeamAlias` |
| `fetchStadiumAliasChanges(since:cancellationToken:)` | StadiumAlias | `StadiumAlias` |
| `fetchSportsForSync(since:cancellationToken:)` | DynamicSport | `Sport` |

All methods accept a `since` date for incremental sync and a `cancellationToken` for graceful termination.

---

### CanonicalSyncService

**File:** `SportsTime/Core/Services/CanonicalSyncService.swift`

The CanonicalSyncService orchestrates the sync process across all entity types, managing progress tracking and cancellation.

#### SyncResult

```swift
struct SyncResult {
    let stadiumsUpdated: Int
    let teamsUpdated: Int
    let gamesUpdated: Int
    let leagueStructuresUpdated: Int
    let teamAliasesUpdated: Int
    let stadiumAliasesUpdated: Int
    let sportsUpdated: Int
    let skippedIncompatible: Int
    let skippedOlder: Int
    let duration: TimeInterval
    let wasCancelled: Bool  // True if sync was interrupted

    var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }
    var totalUpdated: Int { /* sum of all updated counts */ }
}
```

#### Sync Order

Entities are synced in dependency order:

1. **Sports** - Must exist before teams reference them
2. **Stadiums** - Must exist before teams reference them
3. **Teams** - Must exist before games reference them
4. **Games** - References teams and stadiums
5. **League Structures** - References teams
6. **Team Aliases** - References teams
7. **Stadium Aliases** - References stadiums

#### Per-Entity Progress

Progress is saved after each entity type completes:

```swift
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
    // 1. Sync sports
    let sportsResult = try await syncSports(context: context, cancellationToken: cancellationToken)
    syncState.lastSportSync = Date()
    try context.save()  // Save progress

    // Check for cancellation
    if cancellationToken?.isCancelled == true {
        return SyncResult(/* ... */, wasCancelled: true)
    }

    // 2. Sync stadiums
    let stadiumsResult = try await syncStadiums(context: context, cancellationToken: cancellationToken)
    syncState.lastStadiumSync = Date()
    try context.save()  // Save progress

    // ... continue for each entity type
}
```

This ensures that if a background task is terminated mid-sync, the next sync will only fetch entities that weren't completed.

#### Sync Mutex

Only one sync can run at a time:

```swift
private var isSyncing = false

func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
    guard !isSyncing else {
        throw SyncError.syncAlreadyInProgress
    }
    isSyncing = true
    defer { isSyncing = false }
    // ... sync logic
}
```

---

### BackgroundSyncManager

**File:** `SportsTime/Core/Services/BackgroundSyncManager.swift`

The BackgroundSyncManager integrates with iOS's BGTaskScheduler to run sync operations when the app is not active.

#### Task Types

| Task | Identifier | Purpose | Schedule |
|------|------------|---------|----------|
| Refresh | `com.sportstime.app.refresh` | Periodic sync | Every ~1 hour (system decides) |
| Processing | `com.sportstime.app.db-cleanup` | Heavy sync + cleanup | Overnight (~2 AM) |

#### Registration

Tasks must be registered before `applicationDidFinishLaunching` returns:

```swift
// In SportsTimeApp.init()
BackgroundSyncManager.shared.registerTasks()
```

```swift
func registerTasks() {
    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: Self.refreshTaskIdentifier,
        using: nil
    ) { task in
        guard let task = task as? BGAppRefreshTask else { return }
        Task { @MainActor in
            await self.handleRefreshTask(task)
        }
    }
    // ... register processing task
}
```

#### Cancellation Integration

When the system needs to terminate a background task, the expiration handler fires:

```swift
private func handleRefreshTask(_ task: BGAppRefreshTask) async {
    // Create cancellation token
    let cancellationToken = BackgroundTaskCancellationToken()
    currentCancellationToken = cancellationToken

    // Set up expiration handler
    task.expirationHandler = { [weak self] in
        self?.logger.warning("Background refresh task expiring - cancelling sync")
        cancellationToken.cancel()
    }

    // Perform sync with cancellation support
    let result = try await performSync(
        context: container.mainContext,
        cancellationToken: cancellationToken
    )

    // Handle result
    if result.wasCancelled {
        // Partial success - progress was saved
        task.setTaskCompleted(success: true)
    } else {
        task.setTaskCompleted(success: !result.isEmpty)
    }
}
```

#### Scheduling

Background tasks are scheduled when:
- App finishes bootstrapping
- App enters background

```swift
func scheduleAllTasks() {
    scheduleRefresh()
    scheduleProcessingTask()
}

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier)
    request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)  // 1 hour
    try? BGTaskScheduler.shared.submit(request)
}

func scheduleProcessingTask() {
    let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier)
    request.earliestBeginDate = /* next 2 AM */
    request.requiresNetworkConnectivity = true
    try? BGTaskScheduler.shared.submit(request)
}
```

---

### NetworkMonitor

**File:** `SportsTime/Core/Services/NetworkMonitor.swift`

The NetworkMonitor uses NWPathMonitor to detect network state changes and trigger sync when connectivity is restored.

#### Why Network Monitoring?

Without network monitoring, the app would only sync:
- On app launch
- On background task execution (system-controlled)

With network monitoring, the app syncs immediately when:
- User exits airplane mode
- WiFi reconnects after being out of range
- Cellular restores after being in a dead zone

#### Debouncing

Network state can flap rapidly during WiFi↔cellular handoffs. A 2.5-second debounce prevents multiple sync triggers:

```swift
private let debounceDelay: TimeInterval = 2.5

private func scheduleDebouncedSync() {
    debounceTask?.cancel()

    debounceTask = Task { @MainActor [weak self] in
        guard let self = self else { return }

        // Wait for debounce delay
        try await Task.sleep(for: .seconds(debounceDelay))

        // Verify still connected
        guard isConnected else { return }

        // Reset offline flag and trigger sync
        hasBeenOffline = false
        await onSyncNeeded?()
    }
}
```

#### State Machine

```
                    ┌─────────────────┐
                    │  App Starts     │
                    │  hasBeenOffline │
                    │     = false     │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
          ┌────────│   Monitoring    │────────┐
          │        └─────────────────┘        │
          │                                   │
     Disconnected                        Connected
          │                                   │
          ▼                                   │
┌─────────────────┐                           │
│ hasBeenOffline  │                           │
│    = true       │                           │
│ Cancel debounce │                           │
└────────┬────────┘                           │
         │                                    │
         │ Reconnected                        │
         └──────────────────┬─────────────────┘
                            │
                            ▼
                   ┌─────────────────┐
                   │ Start debounce  │
                   │   (2.5 sec)     │
                   └────────┬────────┘
                            │
             ┌──────────────┴──────────────┐
             │                             │
        Still connected              Disconnected again
             │                             │
             ▼                             ▼
    ┌─────────────────┐          ┌─────────────────┐
    │  Trigger sync   │          │ Cancel debounce │
    │ hasBeenOffline  │          │ Wait for next   │
    │    = false      │          │ reconnection    │
    └─────────────────┘          └─────────────────┘
```

#### Integration

The NetworkMonitor is wired up during app bootstrap:

```swift
// In BootstrappedContentView.performBootstrap()
NetworkMonitor.shared.onSyncNeeded = {
    await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
}
NetworkMonitor.shared.startMonitoring()
```

---

### SyncCancellationToken

**File:** `SportsTime/Core/Services/SyncCancellationToken.swift`

The SyncCancellationToken protocol enables graceful cancellation of sync operations.

#### Protocol

```swift
protocol SyncCancellationToken: Sendable {
    var isCancelled: Bool { get }
}
```

#### Implementation

```swift
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
    private let lock = OSAllocatedUnfairLock(initialState: false)

    var isCancelled: Bool {
        lock.withLock { $0 }
    }

    func cancel() {
        lock.withLock { $0 = true }
    }
}
```

The implementation uses `OSAllocatedUnfairLock` for thread-safe access:
- The expiration handler runs on an arbitrary thread
- The sync operation runs on the MainActor
- The lock ensures both can safely access `isCancelled`

#### Usage in Sync

Cancellation is checked at strategic points:

1. **Between entity types** - After completing stadiums, check before starting teams
2. **Between pagination pages** - After fetching 400 records, check before fetching the next page
3. **Never mid-entity** - A single entity's sync always completes atomically

---

## Data Flow

### App Launch Sync

```
SportsTimeApp.init()
    │
    └─► BackgroundSyncManager.registerTasks()

BootstrappedContentView.task
    │
    └─► performBootstrap()
        │
        ├─► BootstrapService.bootstrapIfNeeded()  // First launch only
        │
        ├─► AppDataProvider.configure()
        │
        ├─► BackgroundSyncManager.configure()
        │
        ├─► AppDataProvider.loadInitialData()
        │
        ├─► NetworkMonitor.startMonitoring()
        │
        ├─► BackgroundSyncManager.scheduleAllTasks()
        │
        └─► Task.detached {
                performBackgroundSync()  // Non-blocking
            }
```

### Foreground Sync

```
scenePhase changes to .active (and hasCompletedInitialSync)
    │
    └─► performBackgroundSync()
        │
        └─► CanonicalSyncService.syncAll()
            │
            ├─► CloudKitService.fetchSportsForSync()
            ├─► CloudKitService.fetchStadiumsForSync()
            ├─► CloudKitService.fetchTeamsForSync()
            ├─► CloudKitService.fetchGamesForSync()
            ├─► CloudKitService.fetchLeagueStructureChanges()
            ├─► CloudKitService.fetchTeamAliasChanges()
            └─► CloudKitService.fetchStadiumAliasChanges()
```

### Background Task Sync

```
BGTaskScheduler triggers task
    │
    └─► BackgroundSyncManager.handleRefreshTask()
        │
        ├─► Create cancellationToken
        │
        ├─► Set expirationHandler → cancellationToken.cancel()
        │
        ├─► performSync(cancellationToken: cancellationToken)
        │   │
        │   └─► CanonicalSyncService.syncAll(cancellationToken: token)
        │       │
        │       ├─► Sync sports → save progress
        │       ├─► Check cancellation
        │       ├─► Sync stadiums → save progress
        │       ├─► Check cancellation
        │       └─► ... (continues until complete or cancelled)
        │
        └─► task.setTaskCompleted(success: result.wasCancelled || !result.isEmpty)
```

### Network Restoration Sync

```
NWPathMonitor detects connectivity
    │
    └─► NetworkMonitor.handlePathUpdate()
        │
        └─► scheduleDebouncedSync()
            │
            └─► (after 2.5s delay)
                │
                └─► onSyncNeeded()
                    │
                    └─► BackgroundSyncManager.triggerSyncFromNetworkRestoration()
                        │
                        └─► performSync()
```

---

## Sync Triggers

| Trigger | When | Cancellable | Notes |
|---------|------|-------------|-------|
| App Launch | `performBootstrap()` | No | Non-blocking, runs in background |
| Foreground Resume | `scenePhase == .active` | No | Only after initial sync completes |
| Network Restoration | `NWPath.status == .satisfied` | No | 2.5s debounce |
| Background Refresh | System decides (~hourly) | Yes | Expiration handler cancels |
| Background Processing | Overnight (~2 AM) | Yes | Includes cleanup task |

---

## Error Handling

### CloudKit Errors

| Error | Handling |
|-------|----------|
| `CKError.networkUnavailable` | Silently continue with local data |
| `CKError.networkFailure` | Silently continue with local data |
| `CKError.serviceUnavailable` | Silently continue with local data |
| `CKError.quotaExceeded` | Log and continue |
| Other errors | Log and propagate |

### Sync Errors

```swift
enum SyncError: LocalizedError {
    case cloudKitUnavailable      // No network - not a failure
    case syncAlreadyInProgress    // Another sync running - skip
    case dataInconsistency(String) // Log but continue
}
```

### Recovery Strategy

1. **Network unavailable**: Use existing local data, sync when network returns
2. **Sync interrupted**: Progress saved per-entity, resume from last checkpoint
3. **Data corruption**: Individual records skipped, sync continues
4. **Background task expired**: Partial progress saved, next task resumes

---

## Debugging

### Console Logging

The sync system uses structured logging:

```swift
private let logger = Logger(subsystem: "com.sportstime.app", category: "BackgroundSyncManager")

logger.info("Background refresh task started")
logger.warning("Background refresh task expiring - cancelling sync")
logger.error("Background refresh failed: \(error.localizedDescription)")
```

Filter in Console.app:
- Subsystem: `com.sportstime.app`
- Categories: `BackgroundSyncManager`, `NetworkMonitor`

### Simulating Background Tasks

In Xcode debugger (LLDB):

```lldb
# Trigger refresh task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.refresh"]

# Trigger processing task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.db-cleanup"]

# Simulate expiration
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.sportstime.app.refresh"]
```

### Checking Sync State

Query SwiftData for sync timestamps:

```swift
let descriptor = FetchDescriptor<SyncState>()
if let syncState = try? context.fetch(descriptor).first {
    print("Last stadium sync: \(syncState.lastStadiumSync ?? "never")")
    print("Last team sync: \(syncState.lastTeamSync ?? "never")")
    print("Last game sync: \(syncState.lastGameSync ?? "never")")
    // ...
}
```

### Network Monitor State

```swift
print("Connected: \(NetworkMonitor.shared.isConnected)")
```

---

## Testing

### Unit Testing Sync

```swift
func testSyncCancellation() async throws {
    let token = BackgroundTaskCancellationToken()

    // Start sync in background
    let syncTask = Task {
        try await syncService.syncAll(context: context, cancellationToken: token)
    }

    // Cancel after short delay
    try await Task.sleep(for: .milliseconds(100))
    token.cancel()

    // Verify partial completion
    let result = try await syncTask.value
    XCTAssertTrue(result.wasCancelled)
    XCTAssertGreaterThan(result.totalUpdated, 0) // Some progress made
}
```

### Integration Testing

1. **Pagination**: Ensure all 5000+ games are fetched
2. **Cancellation**: Verify progress is saved when cancelled
3. **Network restoration**: Test airplane mode toggle triggers sync
4. **Background tasks**: Use simulator LLDB commands

### Manual Testing Checklist

- [ ] Fresh install syncs all data
- [ ] App works offline after initial sync
- [ ] Foreground resume triggers sync
- [ ] Airplane mode → disable triggers sync on restore
- [ ] Background refresh runs (check Console.app)
- [ ] Long sync can be interrupted and resumed

---

## Info.plist Configuration

The following keys must be present in `Info.plist`:

```xml
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.sportstime.app.refresh</string>
    <string>com.sportstime.app.db-cleanup</string>
</array>
<key>UIBackgroundModes</key>
<array>
    <string>fetch</string>
    <string>processing</string>
</array>
```

---

## Performance Considerations

### Memory

- Pagination limits memory usage to ~400 records at a time
- Records are processed and saved incrementally
- Old games (>1 year) are soft-deleted during overnight processing

### Battery

- Network monitoring is passive (no polling)
- Debounce prevents rapid sync triggers
- Heavy processing scheduled for overnight (plugged in)

### Network

- Incremental sync fetches only changes since last sync
- Pagination respects CloudKit rate limits
- Offline state handled gracefully

---

## Future Improvements

1. **Exponential backoff**: Retry failed syncs with increasing delays
2. **Conflict resolution**: Handle concurrent edits from multiple devices
3. **Sync priority**: Prioritize user-relevant data (upcoming games)
4. **Delta sync**: Use CloudKit subscriptions for push-based updates
5. **Metrics**: Track sync success rates, durations, and data freshness
