Files
yattee/Yattee/Services/CloudKit/CloudKitRecordMapper.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

1114 lines
51 KiB
Swift

//
// CloudKitRecordMapper.swift
// Yattee
//
// Maps SwiftData models to/from CloudKit CKRecords.
//
import CloudKit
import Foundation
/// Maps SwiftData models to CloudKit records and vice versa.
///
/// This MUST be `@MainActor`: the `toCKRecord(...)` methods read live SwiftData
/// `@Model` properties (e.g. `LocalPlaylistItem.authorName`) and the `to<Model>`
/// methods create/insert models. SwiftData models are bound to the main
/// `ModelContext` and are not thread-safe — when this was a standalone `actor`,
/// `await recordMapper.toCKRecord(...)` hopped off the main actor and read the
/// backing store from the wrong executor, causing EXC_BAD_ACCESS crashes.
@MainActor
final class CloudKitRecordMapper {
private let zone: CKRecordZone
/// Current schema version for CloudKit records.
/// Increment this when record structure changes.
static let currentSchemaVersion: Int64 = 2
private var currentSchemaVersion: Int64 { Self.currentSchemaVersion }
init(zone: CKRecordZone) {
self.zone = zone
}
// MARK: - Subscription Mapping
/// Converts a Subscription to a CKRecord.
func toCKRecord(subscription: Subscription) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: subscription.sourceRawValue,
globalProvider: subscription.providerName,
instanceURLString: subscription.instanceURLString,
externalExtractor: nil
)
let recordID = SyncableRecordType.subscription(channelID: subscription.channelID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.subscription, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Channel Identity
record["channelID"] = subscription.channelID as CKRecordValue
record["sourceRawValue"] = subscription.sourceRawValue as CKRecordValue
record["instanceURLString"] = subscription.instanceURLString as CKRecordValue?
// Channel Metadata
record["name"] = subscription.name as CKRecordValue
record["channelDescription"] = subscription.channelDescription as CKRecordValue?
record["subscriberCount"] = subscription.subscriberCount.map { Int64($0) } as CKRecordValue?
record["avatarURLString"] = subscription.avatarURLString as CKRecordValue?
record["bannerURLString"] = subscription.bannerURLString as CKRecordValue?
record["isVerified"] = (subscription.isVerified ? 1 : 0) as CKRecordValue
// Subscription Metadata
record["subscribedAt"] = subscription.subscribedAt as CKRecordValue
record["lastUpdatedAt"] = subscription.lastUpdatedAt as CKRecordValue
record["providerName"] = subscription.providerName as CKRecordValue?
return record
}
/// Creates a Subscription from a CKRecord.
func toSubscription(from record: CKRecord) throws -> Subscription {
let recordType = RecordType.subscription
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let channelID = record["channelID"] as? String else {
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let name = record["name"] as? String else {
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
}
guard let subscribedAt = record["subscribedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "subscribedAt", recordType: recordType)
}
guard let lastUpdatedAt = record["lastUpdatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "lastUpdatedAt", recordType: recordType)
}
// Create subscription
let subscription = Subscription(
channelID: channelID,
sourceRawValue: sourceRawValue,
instanceURLString: record["instanceURLString"] as? String,
name: name,
channelDescription: record["channelDescription"] as? String,
subscriberCount: (record["subscriberCount"] as? Int64).map(Int.init),
avatarURLString: record["avatarURLString"] as? String,
bannerURLString: record["bannerURLString"] as? String,
isVerified: (record["isVerified"] as? Int64) == 1
)
// Set timestamps
subscription.subscribedAt = subscribedAt
subscription.lastUpdatedAt = lastUpdatedAt
subscription.providerName = record["providerName"] as? String
return subscription
}
// MARK: - WatchEntry Mapping
/// Converts a WatchEntry to a CKRecord.
func toCKRecord(watchEntry: WatchEntry) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: watchEntry.sourceRawValue,
globalProvider: watchEntry.globalProvider,
instanceURLString: watchEntry.instanceURLString,
externalExtractor: watchEntry.externalExtractor
)
let recordID = SyncableRecordType.watchEntry(videoID: watchEntry.videoID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.watchEntry, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Video Identity
record["videoID"] = watchEntry.videoID as CKRecordValue
record["sourceRawValue"] = watchEntry.sourceRawValue as CKRecordValue
record["globalProvider"] = watchEntry.globalProvider as CKRecordValue?
record["instanceURLString"] = watchEntry.instanceURLString as CKRecordValue?
record["peertubeUUID"] = watchEntry.peertubeUUID as CKRecordValue?
record["externalExtractor"] = watchEntry.externalExtractor as CKRecordValue?
record["externalURLString"] = watchEntry.externalURLString as CKRecordValue?
// Video Metadata (cached)
record["title"] = watchEntry.title as CKRecordValue
record["authorName"] = watchEntry.authorName as CKRecordValue
record["authorID"] = watchEntry.authorID as CKRecordValue
record["duration"] = watchEntry.duration as CKRecordValue
record["thumbnailURLString"] = watchEntry.thumbnailURLString as CKRecordValue?
// Watch Progress
record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue
record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue
record["finishedAt"] = watchEntry.finishedAt as CKRecordValue?
record["isLive"] = (watchEntry.isLive ? 1 : 0) as CKRecordValue
// Timestamps
record["createdAt"] = watchEntry.createdAt as CKRecordValue
record["updatedAt"] = watchEntry.updatedAt as CKRecordValue
return record
}
/// Creates a WatchEntry from a CKRecord.
func toWatchEntry(from record: CKRecord) throws -> WatchEntry {
let recordType = RecordType.watchEntry
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let videoID = record["videoID"] as? String else {
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let title = record["title"] as? String else {
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
}
guard let authorName = record["authorName"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
}
guard let authorID = record["authorID"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
}
guard let duration = record["duration"] as? Double else {
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
}
guard let watchedSeconds = record["watchedSeconds"] as? Double else {
throw CloudKitError.missingRequiredField(field: "watchedSeconds", recordType: recordType)
}
guard let isFinishedInt = record["isFinished"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "isFinished", recordType: recordType)
}
guard let createdAt = record["createdAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
}
guard let updatedAt = record["updatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
}
// Create watch entry
let watchEntry = WatchEntry(
videoID: videoID,
sourceRawValue: sourceRawValue,
globalProvider: record["globalProvider"] as? String,
instanceURLString: record["instanceURLString"] as? String,
peertubeUUID: record["peertubeUUID"] as? String,
externalExtractor: record["externalExtractor"] as? String,
externalURLString: record["externalURLString"] as? String,
title: title,
authorName: authorName,
authorID: authorID,
duration: duration,
thumbnailURLString: record["thumbnailURLString"] as? String,
watchedSeconds: watchedSeconds,
isFinished: isFinishedInt == 1
)
// Set timestamps
watchEntry.createdAt = createdAt
watchEntry.updatedAt = updatedAt
watchEntry.finishedAt = record["finishedAt"] as? Date
// Optional field - records written before this field existed default to false,
// so it is read leniently rather than bumping the schema version.
watchEntry.isLive = (record["isLive"] as? Int64) == 1
return watchEntry
}
// MARK: - Bookmark Mapping
/// Converts a Bookmark to a CKRecord.
func toCKRecord(bookmark: Bookmark) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: bookmark.sourceRawValue,
globalProvider: bookmark.globalProvider,
instanceURLString: bookmark.instanceURLString,
externalExtractor: bookmark.externalExtractor
)
let recordID = SyncableRecordType.bookmark(videoID: bookmark.videoID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.bookmark, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Video Identity
record["videoID"] = bookmark.videoID as CKRecordValue
record["sourceRawValue"] = bookmark.sourceRawValue as CKRecordValue
record["globalProvider"] = bookmark.globalProvider as CKRecordValue?
record["instanceURLString"] = bookmark.instanceURLString as CKRecordValue?
record["peertubeUUID"] = bookmark.peertubeUUID as CKRecordValue?
record["externalExtractor"] = bookmark.externalExtractor as CKRecordValue?
record["externalURLString"] = bookmark.externalURLString as CKRecordValue?
// Video Metadata (cached)
record["title"] = bookmark.title as CKRecordValue
record["authorName"] = bookmark.authorName as CKRecordValue
record["authorID"] = bookmark.authorID as CKRecordValue
record["duration"] = bookmark.duration as CKRecordValue
record["thumbnailURLString"] = bookmark.thumbnailURLString as CKRecordValue?
record["isLive"] = (bookmark.isLive ? 1 : 0) as CKRecordValue
record["viewCount"] = bookmark.viewCount.map { Int64($0) } as CKRecordValue?
record["publishedAt"] = bookmark.publishedAt as CKRecordValue?
record["publishedText"] = bookmark.publishedText as CKRecordValue?
// Bookmark Metadata
record["createdAt"] = bookmark.createdAt as CKRecordValue
record["note"] = bookmark.note as CKRecordValue?
record["noteModifiedAt"] = bookmark.noteModifiedAt as CKRecordValue?
record["tags"] = bookmark.tags as CKRecordValue
record["tagsModifiedAt"] = bookmark.tagsModifiedAt as CKRecordValue?
record["sortOrder"] = Int64(bookmark.sortOrder) as CKRecordValue
return record
}
/// Creates a Bookmark from a CKRecord.
func toBookmark(from record: CKRecord) throws -> Bookmark {
let recordType = RecordType.bookmark
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let videoID = record["videoID"] as? String else {
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let title = record["title"] as? String else {
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
}
guard let authorName = record["authorName"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
}
guard let authorID = record["authorID"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
}
guard let duration = record["duration"] as? Double else {
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
}
guard let isLiveInt = record["isLive"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "isLive", recordType: recordType)
}
guard let createdAt = record["createdAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
}
guard let sortOrderInt = record["sortOrder"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "sortOrder", recordType: recordType)
}
// Create bookmark
let bookmark = Bookmark(
videoID: videoID,
sourceRawValue: sourceRawValue,
globalProvider: record["globalProvider"] as? String,
instanceURLString: record["instanceURLString"] as? String,
peertubeUUID: record["peertubeUUID"] as? String,
externalExtractor: record["externalExtractor"] as? String,
externalURLString: record["externalURLString"] as? String,
title: title,
authorName: authorName,
authorID: authorID,
duration: duration,
thumbnailURLString: record["thumbnailURLString"] as? String,
isLive: isLiveInt == 1,
viewCount: (record["viewCount"] as? Int64).map(Int.init),
publishedAt: record["publishedAt"] as? Date,
publishedText: record["publishedText"] as? String,
note: record["note"] as? String,
noteModifiedAt: record["noteModifiedAt"] as? Date,
tags: record["tags"] as? [String] ?? [],
tagsModifiedAt: record["tagsModifiedAt"] as? Date,
sortOrder: Int(sortOrderInt)
)
// Set timestamp
bookmark.createdAt = createdAt
return bookmark
}
// MARK: - LocalPlaylist Mapping
/// Converts a LocalPlaylist to a CKRecord.
func toCKRecord(playlist: LocalPlaylist) -> CKRecord {
let recordID = SyncableRecordType.localPlaylist(id: playlist.id).recordID(in: zone)
let record = CKRecord(recordType: RecordType.localPlaylist, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Playlist Metadata
record["playlistID"] = playlist.id.uuidString as CKRecordValue
record["title"] = playlist.title as CKRecordValue
record["playlistDescription"] = playlist.playlistDescription as CKRecordValue?
record["createdAt"] = playlist.createdAt as CKRecordValue
record["updatedAt"] = playlist.updatedAt as CKRecordValue
return record
}
/// Creates a LocalPlaylist from a CKRecord.
func toLocalPlaylist(from record: CKRecord) throws -> LocalPlaylist {
let recordType = RecordType.localPlaylist
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let playlistIDString = record["playlistID"] as? String else {
throw CloudKitError.missingRequiredField(field: "playlistID", recordType: recordType)
}
guard let playlistID = UUID(uuidString: playlistIDString) else {
throw CloudKitError.typeMismatch(field: "playlistID", recordType: recordType, expected: "valid UUID string")
}
guard let title = record["title"] as? String else {
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
}
guard let createdAt = record["createdAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
}
guard let updatedAt = record["updatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
}
// Create playlist
let playlist = LocalPlaylist(
id: playlistID,
title: title,
description: record["playlistDescription"] as? String
)
// Set timestamps
playlist.createdAt = createdAt
playlist.updatedAt = updatedAt
return playlist
}
// MARK: - LocalPlaylistItem Mapping
/// Converts a LocalPlaylistItem to a CKRecord.
func toCKRecord(playlistItem: LocalPlaylistItem) -> CKRecord {
let recordID = SyncableRecordType.localPlaylistItem(id: playlistItem.id).recordID(in: zone)
let record = CKRecord(recordType: RecordType.localPlaylistItem, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Item Identity
record["itemID"] = playlistItem.id.uuidString as CKRecordValue
record["playlistID"] = playlistItem.playlist?.id.uuidString as CKRecordValue?
record["sortOrder"] = Int64(playlistItem.sortOrder) as CKRecordValue
// Video Identity
record["videoID"] = playlistItem.videoID as CKRecordValue
record["sourceRawValue"] = playlistItem.sourceRawValue as CKRecordValue
record["globalProvider"] = playlistItem.globalProvider as CKRecordValue?
record["instanceURLString"] = playlistItem.instanceURLString as CKRecordValue?
record["peertubeUUID"] = playlistItem.peertubeUUID as CKRecordValue?
record["externalExtractor"] = playlistItem.externalExtractor as CKRecordValue?
record["externalURLString"] = playlistItem.externalURLString as CKRecordValue?
// Video Metadata
record["title"] = playlistItem.title as CKRecordValue
record["authorName"] = playlistItem.authorName as CKRecordValue
record["authorID"] = playlistItem.authorID as CKRecordValue
record["duration"] = playlistItem.duration as CKRecordValue
record["thumbnailURLString"] = playlistItem.thumbnailURLString as CKRecordValue?
record["isLive"] = (playlistItem.isLive ? 1 : 0) as CKRecordValue
record["addedAt"] = playlistItem.addedAt as CKRecordValue
return record
}
/// Creates a LocalPlaylistItem from a CKRecord.
func toLocalPlaylistItem(from record: CKRecord) throws -> (item: LocalPlaylistItem, playlistID: UUID?) {
let recordType = RecordType.localPlaylistItem
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let itemIDString = record["itemID"] as? String else {
throw CloudKitError.missingRequiredField(field: "itemID", recordType: recordType)
}
guard let itemID = UUID(uuidString: itemIDString) else {
throw CloudKitError.typeMismatch(field: "itemID", recordType: recordType, expected: "valid UUID string")
}
guard let sortOrderInt = record["sortOrder"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "sortOrder", recordType: recordType)
}
guard let videoID = record["videoID"] as? String else {
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let title = record["title"] as? String else {
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
}
guard let authorName = record["authorName"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
}
guard let authorID = record["authorID"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
}
guard let duration = record["duration"] as? Double else {
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
}
guard let isLiveInt = record["isLive"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "isLive", recordType: recordType)
}
guard let addedAt = record["addedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "addedAt", recordType: recordType)
}
// Extract playlist ID
let playlistID: UUID?
if let playlistIDString = record["playlistID"] as? String {
playlistID = UUID(uuidString: playlistIDString)
} else {
playlistID = nil
}
// Create playlist item
let item = LocalPlaylistItem(
id: itemID,
sortOrder: Int(sortOrderInt),
videoID: videoID,
sourceRawValue: sourceRawValue,
globalProvider: record["globalProvider"] as? String,
instanceURLString: record["instanceURLString"] as? String,
peertubeUUID: record["peertubeUUID"] as? String,
externalExtractor: record["externalExtractor"] as? String,
externalURLString: record["externalURLString"] as? String,
title: title,
authorName: authorName,
authorID: authorID,
duration: duration,
thumbnailURLString: record["thumbnailURLString"] as? String,
isLive: isLiveInt == 1
)
// Set timestamp
item.addedAt = addedAt
return (item, playlistID)
}
// MARK: - SearchHistory Mapping
/// Converts a SearchHistory to a CKRecord.
func toCKRecord(searchHistory: SearchHistory) -> CKRecord {
let recordID = SyncableRecordType.searchHistory(id: searchHistory.id).recordID(in: zone)
let record = CKRecord(recordType: RecordType.searchHistory, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["searchID"] = searchHistory.id.uuidString as CKRecordValue
record["query"] = searchHistory.query as CKRecordValue
record["searchedAt"] = searchHistory.searchedAt as CKRecordValue
return record
}
/// Creates a SearchHistory from a CKRecord.
func toSearchHistory(from record: CKRecord) throws -> SearchHistory {
let recordType = RecordType.searchHistory
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let searchIDString = record["searchID"] as? String else {
throw CloudKitError.missingRequiredField(field: "searchID", recordType: recordType)
}
guard let searchID = UUID(uuidString: searchIDString) else {
throw CloudKitError.typeMismatch(field: "searchID", recordType: recordType, expected: "valid UUID string")
}
guard let query = record["query"] as? String else {
throw CloudKitError.missingRequiredField(field: "query", recordType: recordType)
}
guard let searchedAt = record["searchedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "searchedAt", recordType: recordType)
}
return SearchHistory(id: searchID, query: query, searchedAt: searchedAt)
}
// MARK: - RecentChannel Mapping
/// Converts a RecentChannel to a CKRecord.
func toCKRecord(recentChannel: RecentChannel) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: recentChannel.sourceRawValue,
globalProvider: nil,
instanceURLString: recentChannel.instanceURLString,
externalExtractor: nil
)
let recordID = SyncableRecordType.recentChannel(channelID: recentChannel.channelID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.recentChannel, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["recentID"] = recentChannel.id.uuidString as CKRecordValue
record["channelID"] = recentChannel.channelID as CKRecordValue
record["sourceRawValue"] = recentChannel.sourceRawValue as CKRecordValue
record["instanceURLString"] = recentChannel.instanceURLString as CKRecordValue?
record["name"] = recentChannel.name as CKRecordValue
record["thumbnailURLString"] = recentChannel.thumbnailURLString as CKRecordValue?
record["subscriberCount"] = recentChannel.subscriberCount.map { Int64($0) } as CKRecordValue?
record["isVerified"] = (recentChannel.isVerified ? 1 : 0) as CKRecordValue
record["visitedAt"] = recentChannel.visitedAt as CKRecordValue
return record
}
/// Creates a RecentChannel from a CKRecord.
func toRecentChannel(from record: CKRecord) throws -> RecentChannel {
let recordType = RecordType.recentChannel
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let recentIDString = record["recentID"] as? String else {
throw CloudKitError.missingRequiredField(field: "recentID", recordType: recordType)
}
guard let recentID = UUID(uuidString: recentIDString) else {
throw CloudKitError.typeMismatch(field: "recentID", recordType: recordType, expected: "valid UUID string")
}
guard let channelID = record["channelID"] as? String else {
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let name = record["name"] as? String else {
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
}
guard let isVerifiedInt = record["isVerified"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "isVerified", recordType: recordType)
}
guard let visitedAt = record["visitedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "visitedAt", recordType: recordType)
}
return RecentChannel(
id: recentID,
channelID: channelID,
sourceRawValue: sourceRawValue,
instanceURLString: record["instanceURLString"] as? String,
name: name,
thumbnailURLString: record["thumbnailURLString"] as? String,
subscriberCount: (record["subscriberCount"] as? Int64).map(Int.init),
isVerified: isVerifiedInt == 1,
visitedAt: visitedAt
)
}
// MARK: - RecentPlaylist Mapping
/// Converts a RecentPlaylist to a CKRecord.
func toCKRecord(recentPlaylist: RecentPlaylist) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: recentPlaylist.sourceRawValue,
globalProvider: nil,
instanceURLString: recentPlaylist.instanceURLString,
externalExtractor: nil
)
let recordID = SyncableRecordType.recentPlaylist(playlistID: recentPlaylist.playlistID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.recentPlaylist, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["recentID"] = recentPlaylist.id.uuidString as CKRecordValue
record["playlistID"] = recentPlaylist.playlistID as CKRecordValue
record["sourceRawValue"] = recentPlaylist.sourceRawValue as CKRecordValue
record["instanceURLString"] = recentPlaylist.instanceURLString as CKRecordValue?
record["title"] = recentPlaylist.title as CKRecordValue
record["authorName"] = recentPlaylist.authorName as CKRecordValue
record["videoCount"] = Int64(recentPlaylist.videoCount) as CKRecordValue
record["thumbnailURLString"] = recentPlaylist.thumbnailURLString as CKRecordValue?
record["visitedAt"] = recentPlaylist.visitedAt as CKRecordValue
return record
}
/// Creates a RecentPlaylist from a CKRecord.
func toRecentPlaylist(from record: CKRecord) throws -> RecentPlaylist {
let recordType = RecordType.recentPlaylist
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let recentIDString = record["recentID"] as? String else {
throw CloudKitError.missingRequiredField(field: "recentID", recordType: recordType)
}
guard let recentID = UUID(uuidString: recentIDString) else {
throw CloudKitError.typeMismatch(field: "recentID", recordType: recordType, expected: "valid UUID string")
}
guard let playlistID = record["playlistID"] as? String else {
throw CloudKitError.missingRequiredField(field: "playlistID", recordType: recordType)
}
guard let sourceRawValue = record["sourceRawValue"] as? String else {
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
}
guard let title = record["title"] as? String else {
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
}
guard let authorName = record["authorName"] as? String else {
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
}
guard let videoCountInt = record["videoCount"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "videoCount", recordType: recordType)
}
guard let visitedAt = record["visitedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "visitedAt", recordType: recordType)
}
return RecentPlaylist(
id: recentID,
playlistID: playlistID,
sourceRawValue: sourceRawValue,
instanceURLString: record["instanceURLString"] as? String,
title: title,
authorName: authorName,
videoCount: Int(videoCountInt),
thumbnailURLString: record["thumbnailURLString"] as? String,
visitedAt: visitedAt
)
}
// MARK: - ChannelNotificationSettings Mapping
/// Converts a ChannelNotificationSettings to a CKRecord.
func toCKRecord(channelNotificationSettings settings: ChannelNotificationSettings) -> CKRecord {
let scope = SourceScope.from(
sourceRawValue: settings.sourceRawValue,
globalProvider: settings.globalProvider,
instanceURLString: settings.instanceURLString,
externalExtractor: nil
)
let recordID = SyncableRecordType.channelNotificationSettings(channelID: settings.channelID, scope: scope).recordID(in: zone)
let record = CKRecord(recordType: RecordType.channelNotificationSettings, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["channelID"] = settings.channelID as CKRecordValue
record["notificationsEnabled"] = (settings.notificationsEnabled ? 1 : 0) as CKRecordValue
record["updatedAt"] = settings.updatedAt as CKRecordValue
record["sourceRawValue"] = settings.sourceRawValue as CKRecordValue
record["instanceURLString"] = settings.instanceURLString as CKRecordValue?
record["globalProvider"] = settings.globalProvider as CKRecordValue?
return record
}
/// Creates a ChannelNotificationSettings from a CKRecord.
func toChannelNotificationSettings(from record: CKRecord) throws -> ChannelNotificationSettings {
let recordType = RecordType.channelNotificationSettings
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let channelID = record["channelID"] as? String else {
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
}
guard let notificationsEnabledInt = record["notificationsEnabled"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "notificationsEnabled", recordType: recordType)
}
guard let updatedAt = record["updatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
}
let settings = ChannelNotificationSettings(
channelID: channelID,
notificationsEnabled: notificationsEnabledInt == 1,
sourceRawValue: (record["sourceRawValue"] as? String) ?? "global",
instanceURLString: record["instanceURLString"] as? String,
globalProvider: record["globalProvider"] as? String
)
settings.updatedAt = updatedAt
return settings
}
// MARK: - LayoutPreset Mapping
/// Converts a LayoutPreset to a CKRecord.
func toCKRecord(preset: LayoutPreset) throws -> CKRecord {
let recordID = SyncableRecordType.controlsPreset(id: preset.id).recordID(in: zone)
let record = CKRecord(recordType: RecordType.controlsPreset, recordID: recordID)
// Schema version
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
// Preset metadata
record["presetID"] = preset.id.uuidString as CKRecordValue
record["name"] = preset.name as CKRecordValue
record["createdAt"] = preset.createdAt as CKRecordValue
record["updatedAt"] = preset.updatedAt as CKRecordValue
record["isBuiltIn"] = (preset.isBuiltIn ? 1 : 0) as CKRecordValue
record["deviceClass"] = preset.deviceClass.rawValue as CKRecordValue
// Encode layout as JSON string
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let layoutData = try encoder.encode(preset.layout)
let layoutJSON = String(data: layoutData, encoding: .utf8) ?? "{}"
record["layoutJSON"] = layoutJSON as CKRecordValue
return record
}
/// Creates a LayoutPreset from a CKRecord.
func toLayoutPreset(from record: CKRecord) throws -> LayoutPreset {
let recordType = RecordType.controlsPreset
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
// Version check - treat missing version as v1 (first versioned release)
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
// Required fields with specific error messages
guard let presetIDString = record["presetID"] as? String else {
throw CloudKitError.missingRequiredField(field: "presetID", recordType: recordType)
}
guard let presetID = UUID(uuidString: presetIDString) else {
throw CloudKitError.typeMismatch(field: "presetID", recordType: recordType, expected: "valid UUID string")
}
guard let name = record["name"] as? String else {
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
}
guard let createdAt = record["createdAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
}
guard let updatedAt = record["updatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
}
guard let isBuiltInInt = record["isBuiltIn"] as? Int64 else {
throw CloudKitError.missingRequiredField(field: "isBuiltIn", recordType: recordType)
}
guard let deviceClassRaw = record["deviceClass"] as? String else {
throw CloudKitError.missingRequiredField(field: "deviceClass", recordType: recordType)
}
guard let deviceClass = DeviceClass(rawValue: deviceClassRaw) else {
throw CloudKitError.typeMismatch(field: "deviceClass", recordType: recordType, expected: "valid DeviceClass value")
}
guard let layoutJSON = record["layoutJSON"] as? String else {
throw CloudKitError.missingRequiredField(field: "layoutJSON", recordType: recordType)
}
guard let layoutData = layoutJSON.data(using: .utf8) else {
throw CloudKitError.typeMismatch(field: "layoutJSON", recordType: recordType, expected: "valid UTF-8 JSON string")
}
// Decode layout
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let layout = try decoder.decode(PlayerControlsLayout.self, from: layoutData)
return LayoutPreset(
id: presetID,
name: name,
createdAt: createdAt,
updatedAt: updatedAt,
isBuiltIn: isBuiltInInt == 1,
deviceClass: deviceClass,
layout: layout
)
}
// MARK: - Account Mapping
/// Converts an `Account` to a CKRecord.
///
/// The login identifier is deliberately absent: it lives in the Keychain
/// beside the credential, so synced metadata never names a user.
func toCKRecord(account: Account) -> CKRecord {
let recordID = SyncableRecordType.account(id: account.id).recordID(in: zone)
let record = CKRecord(recordType: RecordType.account, recordID: recordID)
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["accountID"] = account.id.uuidString as CKRecordValue
record["instanceID"] = account.instanceID.uuidString as CKRecordValue
record["displayName"] = account.displayName as CKRecordValue?
record["dateAdded"] = account.dateAdded as CKRecordValue
record["updatedAt"] = account.updatedAt as CKRecordValue
return record
}
/// Creates an `Account` from a CKRecord.
func toAccount(from record: CKRecord) throws -> Account {
let recordType = RecordType.account
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
guard let accountIDString = record["accountID"] as? String else {
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
}
guard let accountID = UUID(uuidString: accountIDString) else {
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
}
guard let instanceIDString = record["instanceID"] as? String else {
throw CloudKitError.missingRequiredField(field: "instanceID", recordType: recordType)
}
guard let instanceID = UUID(uuidString: instanceIDString) else {
throw CloudKitError.typeMismatch(field: "instanceID", recordType: recordType, expected: "valid UUID string")
}
guard let dateAdded = record["dateAdded"] as? Date else {
throw CloudKitError.missingRequiredField(field: "dateAdded", recordType: recordType)
}
guard let updatedAt = record["updatedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
}
// The record name is the identity every device agrees on; a record whose
// payload disagrees with it would resolve conflicts against a different
// account than the one it overwrites.
guard record.recordID.recordName == AccountRecordName.account(accountID) else {
throw CloudKitError.typeMismatch(
field: "accountID",
recordType: recordType,
expected: "value matching the record name"
)
}
return Account(
id: accountID,
instanceID: instanceID,
displayName: record["displayName"] as? String,
dateAdded: dateAdded,
updatedAt: updatedAt
)
}
// MARK: - Legacy Account Migration Marker Mapping
/// A permanent marker recording that this instance's legacy login has been
/// turned into an account. Write-once: it carries no mutable state.
func toCKRecord(legacyMigrationInstanceID instanceID: UUID) -> CKRecord {
let recordID = SyncableRecordType.legacyAccountMigration(instanceID: instanceID).recordID(in: zone)
let record = CKRecord(recordType: RecordType.legacyAccountMigration, recordID: recordID)
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["instanceID"] = instanceID.uuidString as CKRecordValue
record["migratedAt"] = Date() as CKRecordValue
return record
}
func toLegacyMigrationInstanceID(from record: CKRecord) throws -> UUID {
let recordType = RecordType.legacyAccountMigration
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
guard let instanceIDString = record["instanceID"] as? String else {
throw CloudKitError.missingRequiredField(field: "instanceID", recordType: recordType)
}
guard let instanceID = UUID(uuidString: instanceIDString) else {
throw CloudKitError.typeMismatch(field: "instanceID", recordType: recordType, expected: "valid UUID string")
}
guard record.recordID.recordName == AccountRecordName.legacyMigration(instanceID) else {
throw CloudKitError.typeMismatch(
field: "instanceID",
recordType: recordType,
expected: "value matching the record name"
)
}
return instanceID
}
// MARK: - Account Deletion Tombstone Mapping
/// A permanent tombstone for a deleted account.
func toCKRecord(accountDeletionID accountID: UUID, deletedAt: Date = Date()) -> CKRecord {
let recordID = SyncableRecordType.accountDeletion(accountID: accountID).recordID(in: zone)
let record = CKRecord(recordType: RecordType.accountDeletion, recordID: recordID)
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["accountID"] = accountID.uuidString as CKRecordValue
record["deletedAt"] = deletedAt as CKRecordValue
return record
}
func toAccountDeletionID(from record: CKRecord) throws -> UUID {
let recordType = RecordType.accountDeletion
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
guard let accountIDString = record["accountID"] as? String else {
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
}
guard let accountID = UUID(uuidString: accountIDString) else {
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
}
guard record.recordID.recordName == AccountRecordName.accountDeletion(accountID) else {
throw CloudKitError.typeMismatch(
field: "accountID",
recordType: recordType,
expected: "value matching the record name"
)
}
return accountID
}
// MARK: - DataContext Mapping
/// The singleton record holding the selected data context.
///
/// `changedAt` is app-managed and is the only ordering ever used for this
/// record — CloudKit's modification date says when a *record* was written,
/// not when the user made a choice.
func toCKRecord(dataContext stored: StoredDataContext) -> CKRecord {
let recordID = SyncableRecordType.dataContext.recordID(in: zone)
let record = CKRecord(recordType: RecordType.dataContext, recordID: recordID)
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
record["changedAt"] = stored.changedAt as CKRecordValue
switch stored.context {
case .local:
record["contextKind"] = "local" as CKRecordValue
record["accountID"] = nil
case let .account(id):
record["contextKind"] = "account" as CKRecordValue
record["accountID"] = id.uuidString as CKRecordValue
}
return record
}
func toStoredDataContext(from record: CKRecord) throws -> StoredDataContext {
let recordType = RecordType.dataContext
guard record.recordType == recordType else {
throw CloudKitError.recordNotFound
}
let version = (record["schemaVersion"] as? Int64) ?? 1
guard version <= currentSchemaVersion else {
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
}
guard let kind = record["contextKind"] as? String else {
throw CloudKitError.missingRequiredField(field: "contextKind", recordType: recordType)
}
guard let changedAt = record["changedAt"] as? Date else {
throw CloudKitError.missingRequiredField(field: "changedAt", recordType: recordType)
}
switch kind {
case "local":
return StoredDataContext(context: .local, changedAt: changedAt)
case "account":
guard let accountIDString = record["accountID"] as? String else {
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
}
guard let accountID = UUID(uuidString: accountIDString) else {
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
}
return StoredDataContext(context: .account(accountID), changedAt: changedAt)
default:
throw CloudKitError.typeMismatch(field: "contextKind", recordType: recordType, expected: "\"local\" or \"account\"")
}
}
}