Let the newer channel notification toggle win its conflict

This commit is contained in:
Arkadiusz Fal
2026-09-08 23:27:06 +02:00
parent 0bfacaa497
commit e0f6b5e6d4
3 changed files with 112 additions and 0 deletions

View File

@@ -333,6 +333,37 @@ actor CloudKitConflictResolver {
return resolved
}
// MARK: - ChannelNotificationSettings Conflict Resolution
/// Resolves a conflict between local and server channel notification settings.
///
/// Strategy:
/// - Last write wins based on updatedAt timestamp — the same rule
/// `DataManager.upsertChannelNotificationSettings` applies on arrival, so a
/// record that wins the upload is never refused by the other device.
/// - Preserve server recordChangeTag
func resolveChannelNotificationSettingsConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
// IMPORTANT: Always start with server record to preserve recordChangeTag
let resolved = server
// If local is newer, copy its fields to server record
if localUpdated > serverUpdated {
resolved["notificationsEnabled"] = local["notificationsEnabled"]
resolved["updatedAt"] = local["updatedAt"]
resolved["sourceRawValue"] = local["sourceRawValue"]
resolved["instanceURLString"] = local["instanceURLString"]
resolved["globalProvider"] = local["globalProvider"]
}
return resolved
}
// MARK: - Account Conflict Resolution
/// Resolves a conflict between local and server account records.

View File

@@ -2112,6 +2112,8 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
return await conflictResolver.resolveRecentPlaylistConflict(local: local, server: server)
case RecordType.controlsPreset:
return await conflictResolver.resolveLayoutPresetConflict(local: local, server: server)
case RecordType.channelNotificationSettings:
return await conflictResolver.resolveChannelNotificationSettingsConflict(local: local, server: server)
case RecordType.account, RecordType.dataContext,
RecordType.legacyAccountMigration, RecordType.accountDeletion:
guard let accountSync else { return server }

View File

@@ -40,6 +40,22 @@ struct CloudKitSyncTests {
return entry
}
@MainActor
private static func makeNotificationSettings(
channelID: String = "channel1",
notificationsEnabled: Bool = false,
updatedAt: Date = Date(timeIntervalSince1970: 1_000)
) -> ChannelNotificationSettings {
let settings = ChannelNotificationSettings(
channelID: channelID,
notificationsEnabled: notificationsEnabled,
sourceRawValue: "global",
globalProvider: "youtube"
)
settings.updatedAt = updatedAt
return settings
}
// MARK: - Record Mapper
@Suite("Record Mapper")
@@ -138,4 +154,67 @@ struct CloudKitSyncTests {
#expect(resolved["watchedSeconds"] as? Double == 0)
}
}
@Suite("Channel Notification Settings Conflicts")
struct ChannelNotificationSettingsConflictTests {
@Test("Newer local toggle wins over the older server value")
@MainActor
func newerLocalToggleWins() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: false,
updatedAt: Date(timeIntervalSince1970: 2_000)
))
let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: true,
updatedAt: Date(timeIntervalSince1970: 1_000)
))
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server)
#expect(resolved["notificationsEnabled"] as? Int64 == 0)
#expect(resolved["updatedAt"] as? Date == Date(timeIntervalSince1970: 2_000))
}
@Test("Newer server toggle keeps its value")
@MainActor
func newerServerToggleWins() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: false,
updatedAt: Date(timeIntervalSince1970: 1_000)
))
let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: true,
updatedAt: Date(timeIntervalSince1970: 2_000)
))
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server)
#expect(resolved["notificationsEnabled"] as? Int64 == 1)
#expect(resolved["updatedAt"] as? Date == Date(timeIntervalSince1970: 2_000))
}
@Test("A local record that only repeats the server value leaves it untouched")
@MainActor
func equalTimestampsKeepServerValue() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let stamp = Date(timeIntervalSince1970: 1_000)
let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: false,
updatedAt: stamp
))
let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings(
notificationsEnabled: true,
updatedAt: stamp
))
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server)
#expect(resolved["notificationsEnabled"] as? Int64 == 1)
}
}
}