mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +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.
1114 lines
51 KiB
Swift
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\"")
|
|
}
|
|
}
|
|
}
|