mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
469 lines
18 KiB
Swift
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
|
|
}
|
|
}
|