mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
428 lines
16 KiB
Swift
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
|
|
}
|
|
}
|