Files
yattee/Yattee/Services/CloudKit/CloudKitConflictResolver.swift
Arkadiusz Fal 794b2b0181 Let accounts be set inactive without signing them out
An inactive account keeps its credential and syncs like a rename, but
drops out of every picker and cannot be used until activated again.
Deactivating the selected account switches to the local context as a
published choice, so other devices follow instead of falling back.
2026-09-19 23:58:24 +02:00

469 lines
18 KiB
Swift

//
// CloudKitConflictResolver.swift
// Yattee
//
// Handles conflict resolution when local and remote records differ.
//
import CloudKit
import Foundation
/// Resolves conflicts between local and remote CloudKit records.
actor CloudKitConflictResolver {
// MARK: - Subscription Conflict Resolution
/// Resolves a conflict between local and server subscription records.
///
/// Strategy:
/// - Keep record with most recent `lastUpdatedAt`
func resolveSubscriptionConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localDate = (local["lastUpdatedAt"] as? Date) ?? Date.distantPast
let serverDate = (server["lastUpdatedAt"] as? Date) ?? Date.distantPast
// IMPORTANT: Always start with server record to preserve recordChangeTag
// This is required for CloudKit to accept the update
let resolved = server
// If local is newer, copy its fields to server record
if localDate > serverDate {
resolved["name"] = local["name"]
resolved["channelDescription"] = local["channelDescription"]
resolved["subscriberCount"] = local["subscriberCount"]
resolved["avatarURLString"] = local["avatarURLString"]
resolved["bannerURLString"] = local["bannerURLString"]
resolved["isVerified"] = local["isVerified"]
resolved["lastUpdatedAt"] = local["lastUpdatedAt"]
resolved["providerName"] = local["providerName"]
}
return resolved
}
// MARK: - WatchEntry Conflict Resolution
/// Resolves a conflict between local and server watch entry records.
///
/// Strategy:
/// - Use most recent updatedAt for watch progress (respects "mark as unwatched")
/// - Use most recent updatedAt for metadata fields
/// - Preserve finishedAt from whichever record marked it finished
func resolveWatchEntryConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localSeconds = (local["watchedSeconds"] as? Double) ?? 0
let serverSeconds = (server["watchedSeconds"] as? Double) ?? 0
let localFinished = ((local["isFinished"] as? Int64) ?? 0) == 1
let serverFinished = ((server["isFinished"] as? Int64) ?? 0) == 1
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
// This is required for CloudKit to accept the update
let resolved = server
// Copy metadata from local if it's newer
if localUpdated > serverUpdated {
resolved["title"] = local["title"]
resolved["authorName"] = local["authorName"]
resolved["authorID"] = local["authorID"]
resolved["duration"] = local["duration"]
resolved["thumbnailURLString"] = local["thumbnailURLString"]
}
// Use watch progress from the most recently updated record
// This respects user intent - if they marked as unwatched, that action wins
if localUpdated > serverUpdated {
resolved["watchedSeconds"] = localSeconds as CKRecordValue
resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = local["finishedAt"]
resolved["isLive"] = local["isLive"]
} else {
resolved["watchedSeconds"] = serverSeconds as CKRecordValue
resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = server["finishedAt"]
resolved["isLive"] = server["isLive"]
}
// Use most recent updatedAt
resolved["updatedAt"] = max(localUpdated, serverUpdated) as CKRecordValue
return resolved
}
// MARK: - Bookmark Conflict Resolution
/// Resolves a conflict between local and server bookmark records.
///
/// Strategy:
/// - Keep most recent createdAt (newest wins)
/// - Preserve local note if it exists
/// - Keep local sortOrder (user's manual ordering)
func resolveBookmarkConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localCreated = (local["createdAt"] as? Date) ?? Date.distantPast
let serverCreated = (server["createdAt"] as? Date) ?? Date.distantPast
// IMPORTANT: Always start with server record to preserve recordChangeTag
let resolved = server
// If local is newer, copy its metadata to server record
if localCreated > serverCreated {
resolved["title"] = local["title"]
resolved["authorName"] = local["authorName"]
resolved["authorID"] = local["authorID"]
resolved["duration"] = local["duration"]
resolved["thumbnailURLString"] = local["thumbnailURLString"]
resolved["isLive"] = local["isLive"]
resolved["viewCount"] = local["viewCount"]
resolved["publishedAt"] = local["publishedAt"]
resolved["publishedText"] = local["publishedText"]
resolved["createdAt"] = local["createdAt"]
}
// Resolve note based on timestamp (most recent wins)
let localNoteModified = local["noteModifiedAt"] as? Date ?? Date.distantPast
let serverNoteModified = server["noteModifiedAt"] as? Date ?? Date.distantPast
if localNoteModified >= serverNoteModified {
// Local note is newer or equal - use local
resolved["note"] = local["note"]
resolved["noteModifiedAt"] = local["noteModifiedAt"]
} else {
// Server note is newer - use server (already in resolved)
// No need to set, already using server record
}
// Resolve tags based on timestamp (most recent wins)
let localTagsModified = local["tagsModifiedAt"] as? Date ?? Date.distantPast
let serverTagsModified = server["tagsModifiedAt"] as? Date ?? Date.distantPast
if localTagsModified >= serverTagsModified {
// Local tags are newer or equal - use local
resolved["tags"] = local["tags"]
resolved["tagsModifiedAt"] = local["tagsModifiedAt"]
} else {
// Server tags are newer - use server (already in resolved)
// No need to set, already using server record
}
// Always preserve local sortOrder (user's manual ordering)
if let localSortOrder = local["sortOrder"] {
resolved["sortOrder"] = localSortOrder
}
return resolved
}
// MARK: - Playlist Conflict Resolution
/// Resolves a conflict between local and server playlist records.
///
/// Strategy:
/// - Keep most recent updatedAt for metadata (title, description)
/// - Preserve server recordChangeTag
func resolveLocalPlaylistConflict(
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 metadata to server record
if localUpdated > serverUpdated {
resolved["title"] = local["title"]
resolved["playlistDescription"] = local["playlistDescription"]
resolved["updatedAt"] = local["updatedAt"]
}
return resolved
}
/// Resolves a conflict between local and server playlist item records.
///
/// Strategy:
/// - Keep most recent addedAt for metadata
/// - Preserve local sortOrder (user's ordering)
func resolveLocalPlaylistItemConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localAdded = (local["addedAt"] as? Date) ?? Date.distantPast
let serverAdded = (server["addedAt"] as? Date) ?? Date.distantPast
// IMPORTANT: Always start with server record to preserve recordChangeTag
let resolved = server
// If local is newer, copy its metadata to server record
if localAdded > serverAdded {
resolved["title"] = local["title"]
resolved["authorName"] = local["authorName"]
resolved["authorID"] = local["authorID"]
resolved["duration"] = local["duration"]
resolved["thumbnailURLString"] = local["thumbnailURLString"]
resolved["isLive"] = local["isLive"]
}
// Always preserve local sortOrder (user's manual ordering)
if let localSortOrder = local["sortOrder"] {
resolved["sortOrder"] = localSortOrder
}
return resolved
}
// MARK: - SearchHistory Conflict Resolution
/// Resolves a conflict between local and server search history records.
///
/// Strategy:
/// - Keep most recent searchedAt (newest wins)
/// - Preserve server recordChangeTag
func resolveSearchHistoryConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localSearched = (local["searchedAt"] as? Date) ?? Date.distantPast
let serverSearched = (server["searchedAt"] 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 localSearched > serverSearched {
resolved["query"] = local["query"]
resolved["searchedAt"] = local["searchedAt"]
}
return resolved
}
// MARK: - RecentChannel Conflict Resolution
/// Resolves a conflict between local and server recent channel records.
///
/// Strategy:
/// - Keep most recent visitedAt (newest wins)
/// - Preserve server recordChangeTag
func resolveRecentChannelConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
let serverVisited = (server["visitedAt"] 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 localVisited > serverVisited {
resolved["name"] = local["name"]
resolved["thumbnailURLString"] = local["thumbnailURLString"]
resolved["subscriberCount"] = local["subscriberCount"]
resolved["isVerified"] = local["isVerified"]
resolved["visitedAt"] = local["visitedAt"]
}
return resolved
}
// MARK: - RecentPlaylist Conflict Resolution
/// Resolves a conflict between local and server recent playlist records.
///
/// Strategy:
/// - Keep most recent visitedAt (newest wins)
/// - Preserve server recordChangeTag
func resolveRecentPlaylistConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
let serverVisited = (server["visitedAt"] 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 localVisited > serverVisited {
resolved["title"] = local["title"]
resolved["authorName"] = local["authorName"]
resolved["videoCount"] = local["videoCount"]
resolved["thumbnailURLString"] = local["thumbnailURLString"]
resolved["visitedAt"] = local["visitedAt"]
}
return resolved
}
// MARK: - LayoutPreset Conflict Resolution
/// Resolves a conflict between local and server layout preset records.
///
/// Strategy:
/// - Last write wins based on updatedAt timestamp
/// - Preserve server recordChangeTag
func resolveLayoutPresetConflict(
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["name"] = local["name"]
resolved["updatedAt"] = local["updatedAt"]
resolved["layoutJSON"] = local["layoutJSON"]
}
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.
/// - An exact tie keeps the server value and needs no deterministic
/// tiebreaker: unlike accounts and data contexts, no writer stamps these
/// settings with a fixed epoch date, so a tie is an accident of the clock
/// rather than something migration produces on every 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.
///
/// Strategy:
/// - Last write wins on the app-managed `updatedAt`, never on CloudKit's
/// modification date: an edit made offline has to stay orderable against
/// a concurrent edit elsewhere, and a migrated account carries an
/// epoch-old date precisely so any real edit outranks it.
/// - An exact tie is settled by the label, so two devices that edited in the
/// same instant — or migrated the same account, all of which are stamped
/// at the epoch — converge instead of each keeping its own. This has to
/// match `Account.isOutranked`, or a record could win the upload and still
/// be refused on arrival.
func resolveAccountConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
let localWins: Bool
if localUpdated != serverUpdated {
localWins = localUpdated > serverUpdated
} else {
localWins = ((local["displayName"] as? String) ?? "") > ((server["displayName"] as? String) ?? "")
}
// IMPORTANT: Always start with server record to preserve recordChangeTag
let resolved = server
if localWins {
resolved["displayName"] = local["displayName"]
// A local record built before the field existed carries no value;
// clearing the server's would flip the account back to active, so
// absence leaves the server copy standing.
if local["isActive"] != nil {
resolved["isActive"] = local["isActive"]
}
resolved["updatedAt"] = local["updatedAt"]
}
return resolved
}
// MARK: - DataContext Conflict Resolution
/// Resolves a conflict between local and server data-context records.
///
/// Strategy: last write wins on the app-managed `changedAt` — the moment
/// the user chose, not the moment a record happened to be written — with
/// an exact tie settled by the selected account's ID.
///
/// The tie is not hypothetical: every migrated context is stamped at the
/// epoch, so two devices migrating different legacy selections produce
/// identical timestamps. This has to match `StoredDataContext.isOutranked`,
/// or a record could win the upload and still be refused on arrival.
func resolveDataContextConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localChanged = (local["changedAt"] as? Date) ?? Date.distantPast
let serverChanged = (server["changedAt"] as? Date) ?? Date.distantPast
let localWins: Bool
if localChanged != serverChanged {
localWins = localChanged > serverChanged
} else {
let localKey = (local["accountID"] as? String) ?? ""
let serverKey = (server["accountID"] as? String) ?? ""
localWins = localKey > serverKey
}
let resolved = server
if localWins {
resolved["contextKind"] = local["contextKind"]
resolved["accountID"] = local["accountID"]
resolved["changedAt"] = local["changedAt"]
}
return resolved
}
// MARK: - Write-Once Conflict Resolution
/// Resolves a conflict on a record that only ever means "this happened":
/// the legacy-migration markers and the account deletion tombstones.
///
/// There is no state to merge — both sides assert the same fact — so the
/// server's copy is kept, which also preserves its change tag.
func resolveWriteOnceConflict(
local _: CKRecord,
server: CKRecord
) -> CKRecord {
server
}
}