mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Make CloudKit apply path scope-aware and re-upload local-newer merges
CloudKit record names carry a source scope suffix so the same channel or video ID can exist under different sources, but the apply path matched local entities by bare ID: incoming records could merge into an entity from a different scope, and a remote deletion of one scope deleted every local entity with that ID and cascaded scoped deletions for all other scopes back to CloudKit. - Match local entities on the full mapper-derived record name when merging incoming records, materializing records for upload, and applying remote deletions. - Remote deletions now remove only the matching entity and no longer queue CloudKit deletions (no echo, no cross-scope cascade). - Insert dedupe for CloudKit-applied records is scope-aware, so a same-ID record from a different source inserts instead of being silently dropped. Remotely added bookmarks also update the fast-lookup cache immediately. - When conflict resolution during a fetch keeps newer local data, the merge result is queued for upload; previously the server (and other devices) kept the stale version unless the entity was edited again. Strict timestamp comparisons prevent re-upload ping-pong between devices.
This commit is contained in:
@@ -119,26 +119,55 @@ extension DataManager {
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
|
||||
/// Gets all bookmarks matching a video ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single bookmark without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteBookmark(_ bookmark: Bookmark) {
|
||||
let videoID = bookmark.videoID
|
||||
modelContext.delete(bookmark)
|
||||
save()
|
||||
|
||||
// Keep the fast-lookup cache accurate; another scope may still
|
||||
// have a bookmark with this video ID
|
||||
if self.bookmark(for: videoID) == nil {
|
||||
cachedBookmarkedVideoIDs.remove(videoID)
|
||||
}
|
||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||
}
|
||||
|
||||
/// Inserts a bookmark into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
||||
func insertBookmark(_ bookmark: Bookmark) {
|
||||
// Check for duplicates
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// video ID can legitimately exist under different sources
|
||||
let videoID = bookmark.videoID
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
let scopeSuffix = bookmark.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
cachedBookmarkedVideoIDs.insert(videoID)
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
cachedBookmarkedVideoIDs.insert(videoID)
|
||||
}
|
||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||
}
|
||||
@@ -180,3 +209,17 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension Bookmark {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: globalProvider,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: externalExtractor
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all notification settings matching a channel ID. The same ID can
|
||||
/// exist under multiple source scopes.
|
||||
func allChannelNotificationSettings(forChannelID channelID: String) -> [ChannelNotificationSettings] {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single notification settings record without queueing a
|
||||
/// CloudKit deletion. Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
|
||||
modelContext.delete(settings)
|
||||
save()
|
||||
}
|
||||
|
||||
/// Deletes notification settings for a channel.
|
||||
/// - Parameter channelID: The channel ID to delete settings for.
|
||||
func deleteNotificationSettings(for channelID: String) {
|
||||
|
||||
@@ -300,14 +300,34 @@ extension DataManager {
|
||||
let descriptor = FetchDescriptor<RecentChannel>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
|
||||
/// Gets all recent channels matching a channel ID. The same ID can exist
|
||||
/// under multiple source scopes.
|
||||
func recentChannelEntries(forChannelID channelID: String) -> [RecentChannel] {
|
||||
let descriptor = FetchDescriptor<RecentChannel>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a recent channel without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteRecentChannelEntry(_ channel: RecentChannel) {
|
||||
modelContext.delete(channel)
|
||||
save()
|
||||
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Inserts a recent channel (for CloudKit sync).
|
||||
func insertRecentChannel(_ recentChannel: RecentChannel) {
|
||||
// Check for duplicates by channelID
|
||||
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// channel ID can legitimately exist under different sources
|
||||
let scopeSuffix = recentChannel.sourceScopeSuffix
|
||||
let existing = recentChannelEntries(forChannelID: recentChannel.channelID)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
modelContext.insert(recentChannel)
|
||||
save()
|
||||
}
|
||||
@@ -467,16 +487,62 @@ extension DataManager {
|
||||
let descriptor = FetchDescriptor<RecentPlaylist>(
|
||||
predicate: #Predicate { $0.playlistID == playlistID }
|
||||
)
|
||||
|
||||
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
|
||||
/// Gets all recent playlists matching a playlist ID. The same ID can
|
||||
/// exist under multiple source scopes.
|
||||
func recentPlaylistEntries(forPlaylistID playlistID: String) -> [RecentPlaylist] {
|
||||
let descriptor = FetchDescriptor<RecentPlaylist>(
|
||||
predicate: #Predicate { $0.playlistID == playlistID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a recent playlist without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteRecentPlaylistEntry(_ playlist: RecentPlaylist) {
|
||||
modelContext.delete(playlist)
|
||||
save()
|
||||
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Inserts a recent playlist (for CloudKit sync).
|
||||
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
|
||||
// Check for duplicates by playlistID
|
||||
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// playlist ID can legitimately exist under different sources
|
||||
let scopeSuffix = recentPlaylist.sourceScopeSuffix
|
||||
let existing = recentPlaylistEntries(forPlaylistID: recentPlaylist.playlistID)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
modelContext.insert(recentPlaylist)
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension RecentChannel {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
private extension RecentPlaylist {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,16 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all subscriptions matching a channel ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func subscriptions(forChannelID channelID: String) -> [Subscription] {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Gets all subscriptions.
|
||||
func subscriptions() -> [Subscription] {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
@@ -219,15 +229,17 @@ extension DataManager {
|
||||
/// Inserts a subscription into the database.
|
||||
/// Used by SubscriptionService for caching server subscriptions locally.
|
||||
func insertSubscription(_ subscription: Subscription) {
|
||||
// Check for duplicates
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// channel ID can legitimately exist under different sources
|
||||
let channelID = subscription.channelID
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
let scopeSuffix = subscription.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
}
|
||||
@@ -394,3 +406,17 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension Subscription {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: providerName,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +128,39 @@ extension DataManager {
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
|
||||
/// Gets all watch entries matching a video ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func watchEntries(forVideoID videoID: String) -> [WatchEntry] {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single watch entry without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteWatchEntry(_ entry: WatchEntry) {
|
||||
modelContext.delete(entry)
|
||||
save()
|
||||
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
|
||||
}
|
||||
|
||||
/// Inserts a watch entry into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote watch history.
|
||||
func insertWatchEntry(_ watchEntry: WatchEntry) {
|
||||
// Check for duplicates
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// video ID can legitimately exist under different sources
|
||||
let videoID = watchEntry.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
let scopeSuffix = watchEntry.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
modelContext.insert(watchEntry)
|
||||
save()
|
||||
}
|
||||
@@ -376,3 +396,17 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension WatchEntry {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: globalProvider,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: externalExtractor
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user