Files
yattee/Yattee/Services/CloudKit/CloudKitConflictResolver.swift
Arkadiusz Fal 0c003c71cc Sync accounts through CloudKit records
A login created after the revamp got a random account UUID that never
left the device, so its credential was keyed to an ID no other device
had and the login was unusable there. Migrated accounts were fine only
because their ID is deterministically the instance ID.

Accounts, the permanent legacy-migration markers, the permanent deletion
tombstones and the selected data context now ride the existing
CKSyncEngine as four record types, with the rules the category needs:

- Absence proves nothing. A fetch resumes from a change token and never
  enumerates the zone, so deletions are records of their own and they
  dominate the account wherever they are seen — including the engine's
  retry-as-insert path, which is how a queued offline edit would
  otherwise resurrect a deleted account.
- Fetched records are staged durably before each delegate event returns
  and applied as a set, tombstones first, gated on the zone's own result
  rather than didFetchChanges, which fires after failed fetches too.
- Nothing activates for an identity that has not bootstrapped: a device
  left on the previous release consumed these record types as unknown
  and advanced its token past them, so each identity fetches the zone
  once from a nil token, outside the engine's own token.
- A disabled category defers records rather than discarding them; every
  persisted artifact is namespaced by identity, and async work is
  generation-checked so a fetch begun under one Apple account can never
  land in another's state.
- Uploads are ordered: a marker never outruns the account it describes,
  and a context naming an account is never published before that account
  is server-confirmed.
2026-08-25 23:56:58 +02:00

403 lines
15 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.
/// - `instanceID` and `dateAdded` are immutable identity, taken from
/// whichever side wins only so a malformed record cannot strip them.
func resolveAccountConflict(
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 localUpdated > serverUpdated {
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.
func resolveDataContextConflict(
local: CKRecord,
server: CKRecord
) -> CKRecord {
let localChanged = (local["changedAt"] as? Date) ?? Date.distantPast
let serverChanged = (server["changedAt"] as? Date) ?? Date.distantPast
let resolved = server
if localChanged > serverChanged {
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
}
}