diff --git a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift index e68554fd..6b5226a3 100644 --- a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift +++ b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift @@ -16,7 +16,9 @@ public final class FeedDB { public var excerpt: String = "" public var fullContent: String = "" public var favorite: Bool = false + public var favoriteModifiedAt: Date = Date.distantPast public var read: Bool = false + public var readModifiedAt: Date = Date.distantPast public var modifiedAt: Date = Date() public init( @@ -31,7 +33,9 @@ public final class FeedDB { excerpt: String = "", fullContent: String = "", favorite: Bool = false, - ead: Bool = false, + favoriteModifiedAt: Date = Date.distantPast, + read: Bool = false, + readModifiedAt: Date = Date.distantPast, modifiedAt: Date = Date() ) { self.postId = postId @@ -45,7 +49,9 @@ public final class FeedDB { self.excerpt = excerpt self.fullContent = fullContent self.favorite = favorite + self.favoriteModifiedAt = favoriteModifiedAt self.read = read + self.readModifiedAt = readModifiedAt self.modifiedAt = modifiedAt } } @@ -75,6 +81,14 @@ extension FeedDB: ModelFavoritable { data.forEach { context.delete($0) } try? context.save() } + + /// Flips `favorite` and stamps `favoriteModifiedAt`, so `deduplicate()` can tell this + /// explicit action apart from a blank sync-created duplicate. + public func toggleFavorite() { + favorite.toggle() + favoriteModifiedAt = Date() + modifiedAt = Date() + } } extension FeedDB: ModelReadable { @@ -83,24 +97,53 @@ extension FeedDB: ModelReadable { guard let context, let data = try? context.fetch(descriptor) else { return } for post in data { - post.read = true - post.modifiedAt = Date() + post.markAsRead() } try? context.save() } + + /// Flips `read` and stamps `readModifiedAt`, so `deduplicate()` can tell this explicit + /// action apart from a blank sync-created duplicate. + public func toggleRead() { + read.toggle() + readModifiedAt = Date() + modifiedAt = Date() + } + + /// Sets `read` to `true` and stamps `readModifiedAt`, so `deduplicate()` can tell this + /// explicit action apart from a blank sync-created duplicate. + public func markAsRead() { + read = true + readModifiedAt = Date() + modifiedAt = Date() + } } +extension FeedDB: ModelPrioritizable {} + extension FeedDB: ModelDuplicable { public static func deduplicate(using context: ModelContext?) { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\FeedDB.pubDate, order: .reverse)]) guard let context, let data = try? context.fetch(descriptor) else { return } - let recordsToDelete = Dictionary(grouping: data, by: \.postId) - .values - .flatMap { $0.sorted { $0.modifiedAt > $1.modifiedAt }.dropFirst() } + FeedDB.resolveDuplicates(in: data, postId: \.postId, modifiedAt: \.modifiedAt) { survivor, group in + if let latestFavorite = FeedDB.latest( + in: group, value: { $0.favorite }, modifiedAt: { $0.favoriteModifiedAt }, + preferOnTie: { candidate, _ in candidate } + ) { + survivor.favorite = latestFavorite.value + survivor.favoriteModifiedAt = latestFavorite.modifiedAt + } + if let latestRead = FeedDB.latest( + in: group, value: { $0.read }, modifiedAt: { $0.readModifiedAt }, + preferOnTie: { candidate, _ in candidate } + ) { + survivor.read = latestRead.value + survivor.readModifiedAt = latestRead.modifiedAt + } + } delete: { context.delete($0) } - recordsToDelete.forEach { context.delete($0) } try? context.save() } } diff --git a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/PodcastDB.swift b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/PodcastDB.swift index f88f8e97..1121ce49 100644 --- a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/PodcastDB.swift +++ b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/PodcastDB.swift @@ -15,8 +15,10 @@ public final class PodcastDB { public var duration: String = "" public var podcastFrame: String = "" public var favorite: Bool = false + public var favoriteModifiedAt: Date = Date.distantPast public var playable: Bool = false public var current: Double = 0.0 + public var progressModifiedAt: Date = Date.distantPast public var modifiedAt: Date = Date() public init( @@ -31,8 +33,10 @@ public final class PodcastDB { duration: String = "", podcastFrame: String = "", favorite: Bool = false, + favoriteModifiedAt: Date = Date.distantPast, playable: Bool = false, current: Double = 0.0, + progressModifiedAt: Date = Date.distantPast, modifiedAt: Date = Date() ) { self.postId = postId @@ -46,8 +50,10 @@ public final class PodcastDB { self.duration = duration self.podcastFrame = podcastFrame self.favorite = favorite + self.favoriteModifiedAt = favoriteModifiedAt self.playable = playable self.current = current + self.progressModifiedAt = progressModifiedAt self.modifiedAt = modifiedAt } } @@ -60,19 +66,51 @@ extension PodcastDB: ModelFavoritable { data.forEach { context.delete($0) } try? context.save() } + + /// Flips `favorite` and stamps `favoriteModifiedAt`, so `deduplicate()` can tell this + /// explicit action apart from a blank sync-created duplicate. + public func toggleFavorite() { + favorite.toggle() + favoriteModifiedAt = Date() + modifiedAt = Date() + } } +extension PodcastDB { + /// Sets `current` and stamps `progressModifiedAt`, so `deduplicate()` can tell this + /// explicit action apart from a blank sync-created duplicate. + public func updateProgress(_ current: Double) { + self.current = current + progressModifiedAt = Date() + modifiedAt = Date() + } +} + +extension PodcastDB: ModelPrioritizable {} + extension PodcastDB: ModelDuplicable { public static func deduplicate(using context: ModelContext?) { let descriptor = FetchDescriptor() guard let context, let data = try? context.fetch(descriptor) else { return } - let recordsToDelete = Dictionary(grouping: data, by: \.pubDate) - .values - .flatMap { $0.sorted { $0.modifiedAt > $1.modifiedAt }.dropFirst() } + PodcastDB.resolveDuplicates(in: data, postId: \.postId, modifiedAt: \.modifiedAt) { survivor, group in + if let latestFavorite = PodcastDB.latest( + in: group, value: { $0.favorite }, modifiedAt: { $0.favoriteModifiedAt }, + preferOnTie: { candidate, _ in candidate } + ) { + survivor.favorite = latestFavorite.value + survivor.favoriteModifiedAt = latestFavorite.modifiedAt + } + if let latestProgress = PodcastDB.latest( + in: group, value: { $0.current }, modifiedAt: { $0.progressModifiedAt }, + preferOnTie: { candidate, current in candidate > current } + ) { + survivor.current = latestProgress.value + survivor.progressModifiedAt = latestProgress.modifiedAt + } + } delete: { context.delete($0) } - recordsToDelete.forEach { context.delete($0) } try? context.save() } } diff --git a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift index e2c084b3..14e4457c 100644 --- a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift +++ b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift @@ -4,6 +4,19 @@ import StorageLibrary import SwiftData import Testing +private struct FieldMergeCase: Sendable { + let aFavorite: Bool + let aFavoriteModifiedAt: Date + let aRead: Bool + let aReadModifiedAt: Date + let bFavorite: Bool + let bFavoriteModifiedAt: Date + let bRead: Bool + let bReadModifiedAt: Date + let expectedFavorite: Bool + let expectedRead: Bool +} + @Suite("FeedDB Tests") @MainActor struct FeedDBTests { @@ -51,6 +64,12 @@ struct FeedDBTests { #expect(feed.favorite == favorite) } + @Test("FeedDB initializer's read parameter actually sets the read property") + func initializationSetsReadFromParameter() { + let feed = FeedDB(postId: "1", read: true) + #expect(feed.read == true) + } + @Test("FeedDB should initialize with default values") func initializationWithDefaults() { // When @@ -321,6 +340,22 @@ struct FeedDBTests { #expect(remaining.isEmpty) } + @Test("toggleFavorite flips favorite and stamps favoriteModifiedAt without touching read") + func toggleFavoriteStampsFavoriteModifiedAt() { + let feed = FeedDB(postId: "1") + feed.read = true + let readModifiedAtBefore = feed.readModifiedAt + + feed.toggleFavorite() + + #expect(feed.favorite == true) + #expect(feed.favoriteModifiedAt != Date.distantPast) + #expect(feed.readModifiedAt == readModifiedAtBefore) + + feed.toggleFavorite() + #expect(feed.favorite == false) + } + // MARK: - ModelReadable Tests @Test("markAllAsRead should mark all unread posts as read") @@ -383,6 +418,30 @@ struct FeedDBTests { #expect(fetched?.modifiedAt != oldDate) } + @Test("markAsRead sets read and stamps readModifiedAt without touching favorite") + func markAsReadStampsReadModifiedAt() { + let feed = FeedDB(postId: "1", favorite: true) + let favoriteModifiedAtBefore = feed.favoriteModifiedAt + + feed.markAsRead() + + #expect(feed.read == true) + #expect(feed.readModifiedAt != Date.distantPast) + #expect(feed.favoriteModifiedAt == favoriteModifiedAtBefore) + } + + @Test("toggleRead flips read and stamps readModifiedAt") + func toggleReadStampsReadModifiedAt() { + let feed = FeedDB(postId: "1") + + feed.toggleRead() + #expect(feed.read == true) + #expect(feed.readModifiedAt != Date.distantPast) + + feed.toggleRead() + #expect(feed.read == false) + } + // MARK: - ModelDuplicable Tests @Test("deduplicate removes duplicate postIds keeping most recently modified") @@ -436,6 +495,108 @@ struct FeedDBTests { #expect(remaining.contains { $0.title == "Y-new" }) } + @Test("deduplicate keeps the most recently modified content even when a different duplicate wins the favorite merge") + func deduplicateDecouplesContentSurvivorFromFavoriteMerge() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let originallyFavorited = FeedDB(postId: "dup-1", title: "Original", favorite: true, favoriteModifiedAt: Date(timeIntervalSince1970: 5000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let freshSync = FeedDB(postId: "dup-1", title: "Fresh Sync", favorite: false, modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(originallyFavorited) + storage.context.insert(freshSync) + try? storage.context.save() + + FeedDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(FeedDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.title == "Fresh Sync") + #expect(remaining.first?.favorite == true) + } + + @Test("deduplicate respects an explicit cross-device unfavorite over an older favorite") + func deduplicateRespectsExplicitUnfavorite() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let favoritedOnDeviceA = FeedDB(postId: "dup-1", favorite: true, favoriteModifiedAt: Date(timeIntervalSince1970: 1000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let unfavoritedOnDeviceB = FeedDB(postId: "dup-1", favorite: false, favoriteModifiedAt: Date(timeIntervalSince1970: 2000), modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(favoritedOnDeviceA) + storage.context.insert(unfavoritedOnDeviceB) + try? storage.context.save() + + FeedDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(FeedDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.favorite == false) + } + + @Test( + "deduplicate merges favorite and read using each field's own timestamp", + arguments: [ + FieldMergeCase( + aFavorite: true, aFavoriteModifiedAt: Date(timeIntervalSince1970: 1000), aRead: true, aReadModifiedAt: Date(timeIntervalSince1970: 1000), + bFavorite: false, bFavoriteModifiedAt: .distantPast, bRead: false, bReadModifiedAt: .distantPast, + expectedFavorite: true, expectedRead: true + ), + FieldMergeCase( + aFavorite: true, aFavoriteModifiedAt: Date(timeIntervalSince1970: 1000), aRead: false, aReadModifiedAt: Date(timeIntervalSince1970: 1000), + bFavorite: false, bFavoriteModifiedAt: Date(timeIntervalSince1970: 2000), bRead: true, bReadModifiedAt: Date(timeIntervalSince1970: 2000), + expectedFavorite: false, expectedRead: true + ), + FieldMergeCase( + aFavorite: true, aFavoriteModifiedAt: Date(timeIntervalSince1970: 2000), aRead: false, aReadModifiedAt: Date(timeIntervalSince1970: 1000), + bFavorite: false, bFavoriteModifiedAt: Date(timeIntervalSince1970: 1000), bRead: true, bReadModifiedAt: Date(timeIntervalSince1970: 2000), + expectedFavorite: true, expectedRead: true + ), + FieldMergeCase( + aFavorite: false, aFavoriteModifiedAt: .distantPast, aRead: false, aReadModifiedAt: .distantPast, + bFavorite: false, bFavoriteModifiedAt: .distantPast, bRead: false, bReadModifiedAt: .distantPast, + expectedFavorite: false, expectedRead: false + ) + ] + ) + fileprivate func deduplicateMergesFieldsByOwnTimestamp(_ testCase: FieldMergeCase) { + let storage = Database(models: [FeedDB.self], inMemory: true) + let recordA = FeedDB(postId: "dup-1", favorite: testCase.aFavorite, favoriteModifiedAt: testCase.aFavoriteModifiedAt, modifiedAt: Date(timeIntervalSince1970: 1000)) + recordA.read = testCase.aRead + recordA.readModifiedAt = testCase.aReadModifiedAt + let recordB = FeedDB(postId: "dup-1", favorite: testCase.bFavorite, favoriteModifiedAt: testCase.bFavoriteModifiedAt, modifiedAt: Date(timeIntervalSince1970: 2000)) + recordB.read = testCase.bRead + recordB.readModifiedAt = testCase.bReadModifiedAt + + storage.context.insert(recordA) + storage.context.insert(recordB) + try? storage.context.save() + + FeedDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(FeedDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.favorite == testCase.expectedFavorite) + #expect(remaining.first?.read == testCase.expectedRead) + } + + @Test("deduplicate breaks an exact favoriteModifiedAt tie by preferring true, regardless of insertion order") + func deduplicateBreaksFavoriteTieDeterministically() { + let insertionOrders: [(first: Bool, second: Bool)] = [(true, false), (false, true)] + + for order in insertionOrders { + let storage = Database(models: [FeedDB.self], inMemory: true) + let first = FeedDB(postId: "dup-1", favorite: order.first, modifiedAt: Date(timeIntervalSince1970: 1000)) + let second = FeedDB(postId: "dup-1", favorite: order.second, modifiedAt: Date(timeIntervalSince1970: 1000)) + + storage.context.insert(first) + storage.context.insert(second) + try? storage.context.save() + + FeedDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(FeedDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.favorite == true) + } + } + @Test("deduplicate is safe on empty database") func deduplicateSafeOnEmpty() { let storage = Database(models: [FeedDB.self], inMemory: true) diff --git a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/PodcastDBTests.swift b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/PodcastDBTests.swift index 7e7ad875..f7ee18e8 100644 --- a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/PodcastDBTests.swift +++ b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/PodcastDBTests.swift @@ -360,14 +360,40 @@ struct PodcastDBTests { #expect(remaining.isEmpty) } + @Test("toggleFavorite flips favorite and stamps favoriteModifiedAt without touching progress") + func toggleFavoriteStampsFavoriteModifiedAt() { + let podcast = PodcastDB(postId: "1", current: 900) + let progressModifiedAtBefore = podcast.progressModifiedAt + + podcast.toggleFavorite() + + #expect(podcast.favorite == true) + #expect(podcast.favoriteModifiedAt != Date.distantPast) + #expect(podcast.progressModifiedAt == progressModifiedAtBefore) + + podcast.toggleFavorite() + #expect(podcast.favorite == false) + } + + @Test("updateProgress sets current and stamps progressModifiedAt without touching favorite") + func updateProgressStampsProgressModifiedAt() { + let podcast = PodcastDB(postId: "1", favorite: true) + let favoriteModifiedAtBefore = podcast.favoriteModifiedAt + + podcast.updateProgress(450) + + #expect(podcast.current == 450) + #expect(podcast.progressModifiedAt != Date.distantPast) + #expect(podcast.favoriteModifiedAt == favoriteModifiedAtBefore) + } + // MARK: - ModelDuplicable Tests - @Test("deduplicate removes duplicates grouped by pubDate keeping most recently modified") + @Test("deduplicate removes duplicate postIds keeping most recently modified") func deduplicateKeepsMostRecentlyModified() { let storage = Database(models: [PodcastDB.self], inMemory: true) - let sharedPubDate = Date(timeIntervalSince1970: 5000) - let older = PodcastDB(postId: "p1", pubDate: sharedPubDate, modifiedAt: Date(timeIntervalSince1970: 1000)) - let newer = PodcastDB(postId: "p2", pubDate: sharedPubDate, modifiedAt: Date(timeIntervalSince1970: 2000)) + let older = PodcastDB(postId: "dup-1", title: "Older", modifiedAt: Date(timeIntervalSince1970: 1000)) + let newer = PodcastDB(postId: "dup-1", title: "Newer", modifiedAt: Date(timeIntervalSince1970: 2000)) storage.context.insert(older) storage.context.insert(newer) @@ -377,15 +403,16 @@ struct PodcastDBTests { let remaining = storage.fetch(PodcastDB.self) #expect(remaining.count == 1) - #expect(remaining.first?.postId == "p2") + #expect(remaining.first?.title == "Newer") } - @Test("deduplicate preserves podcasts with distinct pubDates") - func deduplicatePreservesDistinctPubDates() { + @Test("deduplicate preserves podcasts with distinct postIds sharing the same pubDate") + func deduplicatePreservesDistinctPostIds() { let storage = Database(models: [PodcastDB.self], inMemory: true) - storage.context.insert(PodcastDB(postId: "1", pubDate: Date(timeIntervalSince1970: 1000))) - storage.context.insert(PodcastDB(postId: "2", pubDate: Date(timeIntervalSince1970: 2000))) - storage.context.insert(PodcastDB(postId: "3", pubDate: Date(timeIntervalSince1970: 3000))) + let sharedPubDate = Date(timeIntervalSince1970: 5000) + storage.context.insert(PodcastDB(postId: "1", pubDate: sharedPubDate)) + storage.context.insert(PodcastDB(postId: "2", pubDate: sharedPubDate)) + storage.context.insert(PodcastDB(postId: "3", pubDate: sharedPubDate)) try? storage.context.save() PodcastDB.deduplicate(using: storage.context) @@ -394,15 +421,14 @@ struct PodcastDBTests { #expect(remaining.count == 3) } - @Test("deduplicate handles triple duplicates for same pubDate") + @Test("deduplicate handles triple duplicates for same postId") func deduplicateHandlesTripleDuplicates() { let storage = Database(models: [PodcastDB.self], inMemory: true) - let sharedDate = Date(timeIntervalSince1970: 9000) let now = Date() - storage.context.insert(PodcastDB(postId: "a", pubDate: sharedDate, modifiedAt: now.addingTimeInterval(-200))) - storage.context.insert(PodcastDB(postId: "b", pubDate: sharedDate, modifiedAt: now.addingTimeInterval(-100))) - storage.context.insert(PodcastDB(postId: "c", title: "Winner", pubDate: sharedDate, modifiedAt: now)) + storage.context.insert(PodcastDB(postId: "dup-1", modifiedAt: now.addingTimeInterval(-200))) + storage.context.insert(PodcastDB(postId: "dup-1", modifiedAt: now.addingTimeInterval(-100))) + storage.context.insert(PodcastDB(postId: "dup-1", title: "Winner", modifiedAt: now)) try? storage.context.save() PodcastDB.deduplicate(using: storage.context) @@ -412,6 +438,120 @@ struct PodcastDBTests { #expect(remaining.first?.title == "Winner") } + @Test("deduplicate keeps the most recently modified content even when a different duplicate wins the favorite merge") + func deduplicateDecouplesContentSurvivorFromFavoriteMerge() { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let originallyFavorited = PodcastDB(postId: "dup-1", title: "Original", favorite: true, favoriteModifiedAt: Date(timeIntervalSince1970: 5000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let freshSync = PodcastDB(postId: "dup-1", title: "Fresh Sync", favorite: false, modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(originallyFavorited) + storage.context.insert(freshSync) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.title == "Fresh Sync") + #expect(remaining.first?.favorite == true) + } + + @Test("deduplicate respects an explicit cross-device unfavorite over an older favorite") + func deduplicateRespectsExplicitUnfavorite() { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let favoritedOnDeviceA = PodcastDB(postId: "dup-1", favorite: true, favoriteModifiedAt: Date(timeIntervalSince1970: 1000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let unfavoritedOnDeviceB = PodcastDB(postId: "dup-1", favorite: false, favoriteModifiedAt: Date(timeIntervalSince1970: 2000), modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(favoritedOnDeviceA) + storage.context.insert(unfavoritedOnDeviceB) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.favorite == false) + } + + @Test("deduplicate never lets a blank sync duplicate override real playback progress") + func deduplicatePreservesProgressAgainstBlankDuplicate() { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let listenedTo = PodcastDB(postId: "dup-1", current: 900, progressModifiedAt: Date(timeIntervalSince1970: 1000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let freshSync = PodcastDB(postId: "dup-1", current: 0, modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(listenedTo) + storage.context.insert(freshSync) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.current == 900) + } + + @Test("deduplicate honors a deliberate rewind even though it lowers the playback position") + func deduplicateHonorsDeliberateRewind() { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let listenedFurtherButStale = PodcastDB(postId: "dup-1", current: 900, progressModifiedAt: Date(timeIntervalSince1970: 1000), modifiedAt: Date(timeIntervalSince1970: 1000)) + let rewoundOnAnotherDevice = PodcastDB(postId: "dup-1", current: 100, progressModifiedAt: Date(timeIntervalSince1970: 2000), modifiedAt: Date(timeIntervalSince1970: 2000)) + + storage.context.insert(listenedFurtherButStale) + storage.context.insert(rewoundOnAnotherDevice) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.current == 100) + } + + @Test("deduplicate breaks an exact progressModifiedAt tie by preferring the larger value, regardless of insertion order") + func deduplicateBreaksProgressTieDeterministically() { + let insertionOrders: [(first: Double, second: Double)] = [(900, 100), (100, 900)] + + for order in insertionOrders { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let first = PodcastDB(postId: "dup-1", current: order.first, modifiedAt: Date(timeIntervalSince1970: 1000)) + let second = PodcastDB(postId: "dup-1", current: order.second, modifiedAt: Date(timeIntervalSince1970: 1000)) + + storage.context.insert(first) + storage.context.insert(second) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.current == 900) + } + } + + @Test("deduplicate merges favorite and progress independently by their own timestamps") + func deduplicateMergesFavoriteAndProgressIndependently() { + let storage = Database(models: [PodcastDB.self], inMemory: true) + let favoritedButNotListened = PodcastDB( + postId: "dup-1", favorite: true, favoriteModifiedAt: Date(timeIntervalSince1970: 2000), + current: 0, modifiedAt: Date(timeIntervalSince1970: 1000) + ) + let listenedButNotFavorited = PodcastDB( + postId: "dup-1", favorite: false, favoriteModifiedAt: Date(timeIntervalSince1970: 1000), + current: 900, progressModifiedAt: Date(timeIntervalSince1970: 2000), modifiedAt: Date(timeIntervalSince1970: 2000) + ) + + storage.context.insert(favoritedButNotListened) + storage.context.insert(listenedButNotFavorited) + try? storage.context.save() + + PodcastDB.deduplicate(using: storage.context) + + let remaining = storage.fetch(PodcastDB.self) + #expect(remaining.count == 1) + #expect(remaining.first?.favorite == true) + #expect(remaining.first?.current == 900) + } + @Test("deduplicate is safe on empty database") func deduplicateSafeOnEmpty() { let storage = Database(models: [PodcastDB.self], inMemory: true) diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift index 22e3ce4a..8b218488 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift @@ -12,3 +12,49 @@ public protocol ModelReadable: AnyObject, PersistentModel { public protocol ModelDuplicable: AnyObject, PersistentModel { static func deduplicate(using context: ModelContext?) } + +public protocol ModelPrioritizable: AnyObject {} + +public extension ModelPrioritizable { + /// Picks the most authoritative value of a field across a group of duplicates, by the + /// field's own timestamp. `preferOnTie` breaks an exact timestamp tie deterministically - + /// independent of the group's iteration order, which SwiftData does not guarantee stable - + /// by returning `true` when `candidate` should replace `current`. + static func latest( + in group: [Self], + value: (Self) -> Value, + modifiedAt: (Self) -> Date, + preferOnTie: (_ candidate: Value, _ current: Value) -> Bool + ) -> (value: Value, modifiedAt: Date)? { + guard var winner = group.first else { return nil } + for candidate in group.dropFirst() { + let winnerDate = modifiedAt(winner) + let candidateDate = modifiedAt(candidate) + if candidateDate > winnerDate { + winner = candidate + } else if candidateDate == winnerDate, preferOnTie(value(candidate), value(winner)) { + winner = candidate + } + } + return (value(winner), modifiedAt(winner)) + } + + /// Groups `data` by `postId`, keeps the most recently modified record per group as the + /// survivor, lets `merge` reconcile per-field state onto it, then hands every other record + /// in the group to `delete`. + static func resolveDuplicates( + in data: [Self], + postId: (Self) -> String, + modifiedAt: (Self) -> Date, + merge: (_ survivor: Self, _ group: [Self]) -> Void, + delete: (Self) -> Void + ) { + for group in Dictionary(grouping: data, by: postId).values where group.count > 1 { + guard let survivor = group.max(by: { modifiedAt($0) < modifiedAt($1) }) else { continue } + merge(survivor, group) + for record in group where record !== survivor { + delete(record) + } + } + } +} diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift index 12805507..798084a7 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift @@ -31,8 +31,7 @@ public extension FeedDB { aspectRatio: aspectRatio, favoriteAction: { [weak self] in guard let self, let context else { return } - self.favorite.toggle() - self.modifiedAt = Date() + self.toggleFavorite() try? context.save() analytics?.track(.buttonTap( buttonId: AnalyticsConstants.ButtonID.newsFavorite.id, @@ -41,8 +40,7 @@ public extension FeedDB { }, readAction: { [weak self] in guard let self, let context else { return } - self.read.toggle() - self.modifiedAt = Date() + self.toggleRead() try? context.save() } ) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift index ff0d0690..da33c595 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift @@ -230,8 +230,7 @@ extension NewsView { .sharedBackgroundVisibility(.hidden) } .task { - viewModel.selectedNews?.read = true - viewModel.selectedNews?.modifiedAt = Date() + viewModel.selectedNews?.markAsRead() try? modelContext.save() } } diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Extensions/PodcastDBExtensions.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Extensions/PodcastDBExtensions.swift index 78fc901e..fb60be6d 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Extensions/PodcastDBExtensions.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Extensions/PodcastDBExtensions.swift @@ -22,8 +22,7 @@ public extension PodcastDB { favorite: self.favorite, favoriteAction: { [weak self] in guard let self, let context else { return } - self.favorite.toggle() - self.modifiedAt = Date() + self.toggleFavorite() try? context.save() analytics?.track(.buttonTap( buttonId: AnalyticsConstants.ButtonID.podcastFavorite.id, @@ -35,8 +34,7 @@ public extension PodcastDB { func save(current: Double, using context: ModelContext?) { guard let context else { return } - self.current = current - self.modifiedAt = Date() + updateProgress(current) try? context.save() } } diff --git a/MacMagazine/MacMagazine/Features/News/DeepLinkNewsDetailView.swift b/MacMagazine/MacMagazine/Features/News/DeepLinkNewsDetailView.swift index 01f246b2..c997dfba 100644 --- a/MacMagazine/MacMagazine/Features/News/DeepLinkNewsDetailView.swift +++ b/MacMagazine/MacMagazine/Features/News/DeepLinkNewsDetailView.swift @@ -44,8 +44,7 @@ struct DeepLinkNewsDetailView: View { .sharedBackgroundVisibility(.hidden) } .onAppear { - post?.read = true - post?.modifiedAt = Date() + post?.markAsRead() try? modelContext.save() } } @@ -57,8 +56,7 @@ struct DeepLinkNewsDetailView: View { FavoriteButton( favorite: post.favorite, action: { - post.favorite.toggle() - post.modifiedAt = Date() + post.toggleFavorite() try? modelContext.save() analytics.track(.buttonTap( buttonId: AnalyticsConstants.ButtonID.newsFavorite.id,