mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
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.
403 lines
15 KiB
Swift
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
|
|
}
|
|
}
|