chore: commit all pending changes
This commit is contained in:
@@ -15,6 +15,17 @@ import os
|
||||
@MainActor
|
||||
final class BackgroundSyncManager {
|
||||
|
||||
enum SyncTriggerError: Error, LocalizedError {
|
||||
case modelContainerNotConfigured
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelContainerNotConfigured:
|
||||
return "Sync is unavailable because the model container is not configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Identifiers
|
||||
|
||||
/// Background app refresh task - runs periodically (system decides frequency)
|
||||
@@ -163,7 +174,7 @@ final class BackgroundSyncManager {
|
||||
task.setTaskCompleted(success: true) // Partial success - progress was saved
|
||||
} else {
|
||||
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
|
||||
task.setTaskCompleted(success: !result.isEmpty)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
} catch {
|
||||
currentCancellationToken = nil
|
||||
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
|
||||
task.setTaskCompleted(success: true) // Partial success
|
||||
} else {
|
||||
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
|
||||
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
} catch {
|
||||
currentCancellationToken = nil
|
||||
@@ -291,6 +302,191 @@ final class BackgroundSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger sync from a CloudKit push notification.
|
||||
/// Returns true if local data changed and UI should refresh.
|
||||
@MainActor
|
||||
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
|
||||
guard let container = modelContainer else {
|
||||
logger.error("Push sync: No model container configured")
|
||||
return false
|
||||
}
|
||||
|
||||
if let subscriptionID {
|
||||
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
|
||||
} else {
|
||||
logger.info("Triggering sync from CloudKit push")
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await performSync(context: container.mainContext)
|
||||
return !result.isEmpty || result.wasCancelled
|
||||
} catch {
|
||||
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a targeted local deletion for records removed remotely.
|
||||
/// This covers deletions which are not returned by date-based delta queries.
|
||||
@MainActor
|
||||
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
|
||||
guard let container = modelContainer else {
|
||||
logger.error("Deletion hint: No model container configured")
|
||||
return false
|
||||
}
|
||||
|
||||
let context = container.mainContext
|
||||
var changed = false
|
||||
|
||||
do {
|
||||
switch recordType {
|
||||
case CKRecordType.game:
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.canonicalId == recordName && game.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let game = try context.fetch(descriptor).first {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.team:
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate<CanonicalTeam> { team in
|
||||
team.canonicalId == recordName && team.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let team = try context.fetch(descriptor).first {
|
||||
team.deprecatedAt = Date()
|
||||
team.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Avoid dangling schedule entries when a referenced team disappears.
|
||||
let impactedGames = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.deprecatedAt == nil &&
|
||||
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
|
||||
}
|
||||
)
|
||||
for game in try context.fetch(impactedGames) {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Dependent team deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.stadium:
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let stadium = try context.fetch(descriptor).first {
|
||||
stadium.deprecatedAt = Date()
|
||||
stadium.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Avoid dangling schedule entries when a referenced stadium disappears.
|
||||
let impactedGames = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
|
||||
}
|
||||
)
|
||||
for game in try context.fetch(impactedGames) {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Dependent stadium deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.leagueStructure:
|
||||
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
||||
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.teamAlias:
|
||||
let descriptor = FetchDescriptor<TeamAlias>(
|
||||
predicate: #Predicate<TeamAlias> { $0.id == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.stadiumAlias:
|
||||
let descriptor = FetchDescriptor<StadiumAlias>(
|
||||
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.sport:
|
||||
let descriptor = FetchDescriptor<CanonicalSport>(
|
||||
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
|
||||
)
|
||||
if let sport = try context.fetch(descriptor).first, sport.isActive {
|
||||
sport.isActive = false
|
||||
sport.lastModified = Date()
|
||||
changed = true
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard changed else { return false }
|
||||
|
||||
try context.save()
|
||||
await AppDataProvider.shared.loadInitialData()
|
||||
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
|
||||
return true
|
||||
|
||||
} catch {
|
||||
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual full sync from settings or other explicit user action.
|
||||
@MainActor
|
||||
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
|
||||
guard let container = modelContainer else {
|
||||
throw SyncTriggerError.modelContainerNotConfigured
|
||||
}
|
||||
|
||||
let context = container.mainContext
|
||||
let syncService = CanonicalSyncService()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
if !syncState.syncEnabled {
|
||||
syncService.resumeSync(context: context)
|
||||
}
|
||||
|
||||
return try await performSync(context: context)
|
||||
}
|
||||
|
||||
/// Register CloudKit subscriptions for canonical data updates.
|
||||
@MainActor
|
||||
func ensureCanonicalSubscriptions() async {
|
||||
do {
|
||||
try await CloudKitService.shared.subscribeToAllUpdates()
|
||||
logger.info("CloudKit subscriptions ensured for canonical sync")
|
||||
} catch {
|
||||
logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up old game data (older than 1 year).
|
||||
@MainActor
|
||||
private func performCleanup(context: ModelContext) async -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user