fix(#304): stop deduplicate() from discarding favorites, read state, and podcast progress - #323
Conversation
…tDB records FeedDB.deduplicate and PodcastDB.deduplicate picked the survivor of a duplicate postId group purely by recency (highest modifiedAt), with no regard for favorite state. Any time a local feed refresh raced an in-flight iCloud import (multi-device sync, or a cold launch right after an app update) and inserted a fresh, non-favorited copy, that fresh copy's modifiedAt would beat the older favorited copy and the favorite was silently deleted. PodcastDB.deduplicate additionally grouped by pubDate instead of postId, so two distinct episodes sharing a pubDate could wrongly be treated as duplicates and have one deleted outright. Both types now share ModelPrioritizable.isLessAuthoritative, which ranks a favorited copy above a merely more-recent one, and merge favorite/read state onto the surviving record before deleting the rest. Co-Authored-By: Claude <noreply@anthropic.com>
PodcastDB has no read flag - current (playback position) is its equivalent per-device engagement state, updated via PodcastDBExtensions.save(current:). deduplicate() picked a survivor without merging it, so a duplicate racing in from iCloud sync or a cold-launch refresh could reset a listener's progress back to zero. Co-Authored-By: Claude <noreply@anthropic.com>
…duplicate() Prior coverage only exercised favorite-vs-recency and one combined favorite+read case. Add a parameterized FeedDB test covering favorite-only, read-only, both-true, and tie-on-favorite merges, plus PodcastDB cases where the favorite winner and the furthest-progress duplicate are different records, and where both duplicates are favorited. Co-Authored-By: Claude <noreply@anthropic.com>
Merging duplicates by a single shared modifiedAt (or OR-ing favorite/read) can't tell a spurious blank sync row apart from a genuine, more recent user action - including the case where the user's real latest action was to *remove* a favorite or mark something unread again. A boolean OR-merge can only turn these on, never off, silently resurrecting an intentional un-favorite made on another device. FeedDB/PodcastDB now carry favoriteModifiedAt/readModifiedAt (and PodcastDB's progressModifiedAt for playback position) alongside their existing modifiedAt, stamped only by the explicit action that changed that specific field. deduplicate() picks the content survivor by plain modifiedAt recency, then merges favorite/read/progress independently by whichever duplicate has the latest timestamp for that field - so a spurious sync row (whose action timestamp defaults to .distantPast) never outranks a real action, and a genuine newer un-favorite/rewind correctly overrides an older favorite/progress. SwiftData's @model macro silently ignores didSet/willSet (confirmed via the guide-swiftdata skill's core rules), so each model now exposes an explicit toggleFavorite()/toggleRead()/markAsRead()/updateProgress() that stamps both the field and its timestamp together, replacing the duplicated three-line pattern at each call site. Co-Authored-By: Claude <noreply@anthropic.com>
Automated code review (Claude Code)🔴 Correctness1.
2.
3.
4.
5.
🟡 Duplication6.
⚪ Style7. 🤖 Generated with Claude Code |
…p, docs - NewsView's article .task and DeepLinkNewsDetailView's onAppear/favorite action mutated read/favorite/modifiedAt directly instead of calling markAsRead()/toggleFavorite(), leaving readModifiedAt/favoriteModifiedAt stale - the exact class of bug this PR fixes, reachable through the primary reading flow and push-notification deep links. - Fixed FeedDB.init's `ead: Bool` parameter typo (missing leading r): self.read = read was silently self-assigning since no `read` parameter existed, so read could never be set to true through the initializer. - ModelPrioritizable.latest() tie-broke on an exact timestamp match using Array.max(by:)'s last-checked-wins behavior, which depends on Dictionary(grouping:) iteration order - unspecified by SwiftData. It now takes an explicit preferOnTie closure and resolves ties independent of input order (favorite/read prefer true; podcast progress prefers the larger value). - Extracted the shared fetch/group/survivor/delete skeleton into ModelPrioritizable.resolveDuplicates(), which FeedDB and PodcastDB now both call - the per-field merge logic was the only genuinely type-specific part. - Added DocC comments to the new public API per CLAUDE.md's comment policy. Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Fixes #304 — favorites (and other per-device user state) being silently lost, most visibly when updating the app, but not limited to that trigger.
Root cause:
FeedDB.deduplicate()/PodcastDB.deduplicate()resolve duplicatepostIdgroups — created whenever a local feed/podcast refresh races an in-flight iCloud import (multi-device sync, or a cold launch right after an app update) — purely by recency (modifiedAt). A freshly-synced, blank duplicate (favorite: false,modifiedAt: Date()at construction) could out-rank an older row that actually carried the user's favorite, silently deleting it.PodcastDBadditionally grouped bypubDateinstead ofpostId, so two unrelated episodes sharing a timestamp could be merged incorrectly.Fix, in three passes (see individual commits):
PodcastDBduplicates bypostId(matchingFeedDB); prefer a favorited/read copy over a merely-newer one.current), which has noreadequivalent but is exposed to the identical race.favoriteModifiedAt,readModifiedAt,progressModifiedAt), because OR-merging can only turn a flag on — it was silently resurrecting a legitimate cross-device un-favorite or "mark unread."deduplicate()now picks the content survivor by plainmodifiedAtrecency, then mergesfavorite/read/currentindependently by whichever duplicate has the latest own timestamp for that specific field — so a spurious sync row (.distantPast) never outranks a real action, and a genuine newer un-favorite/mark-unread/rewind correctly overrides an older one.Confirmed via the
guide-swiftdataskill that@Modelsilently ignoresdidSet/willSet, so each field-mutation is centralized instead in explicit model methods (toggleFavorite(),toggleRead(),markAsRead(),updateProgress(_:)) that stamp the field and its timestamp together, replacing a duplicated 3-line pattern at every call site.Test plan
xcodebuild buildsucceeds (iPhone 17 Pro Max simulator — iPhone 17 Pro is not installed on this machine)xcodebuild test -testPlan MacMagazine— 108 tests, 0 failuresswiftlint lint --strict— 0 violations across all changed filestoggleFavorite/toggleRead/markAsRead/updateProgresstimestamp isolationCo-Authored-By: Claude noreply@anthropic.com