Files
yattee/Yattee/Services/CloudKit/CloudKitConflictResolver.swift
Arkadiusz Fal b37ca40d37 Settle account ties and stop discarding caches on every launch
Two devices editing an account in the same instant — or migrating one,
since every migrated account is stamped at the epoch — each kept its own
label and considered the matter closed. Equal timestamps are now broken
by the label, on the incoming side and in the conflict resolver alike,
the same rule the context already uses.

Cached feeds were thrown away on every ordinary launch: recomputing
statuses posts a notification, and the observer ran before the value it
compares against had been seeded. The seed now happens first.

Cache ownership is checked once at launch and compared against a
recorded owner afterwards. An unresolved identity at launch means the
owner cannot be established at all — a switch to an unseen Apple account
while offline looks exactly like that — so the caches are discarded;
mid-session it is a transient, and with iCloud Drive off it happens on
every activation, so it no longer clears anything.

Whether a Keychain item syncs is fixed when it is written, so a login
stored while sync was off stayed on this device while its account record
travelled, leaving every other device with an account whose credential
never arrives. Turning sync on rewrites them.

Reseeding a deleted zone marks the account records dirty even while the
category is off, so re-enabling it restores them instead of finding an
empty outbox.
2026-08-26 23:53:29 +02:00

428 lines
16 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: - 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"]
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
}
}