Skip to content

fix(#304): stop deduplicate() from discarding favorites, read state, and podcast progress - #323

Merged
cassio-rossi merged 5 commits into
release/v5from
fix/304-favorite-lost-on-dedup
Jul 29, 2026
Merged

fix(#304): stop deduplicate() from discarding favorites, read state, and podcast progress#323
cassio-rossi merged 5 commits into
release/v5from
fix/304-favorite-lost-on-dedup

Conversation

@cassio-rossi

Copy link
Copy Markdown
Collaborator

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 duplicate postId groups — 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. PodcastDB additionally grouped by pubDate instead of postId, so two unrelated episodes sharing a timestamp could be merged incorrectly.

Fix, in three passes (see individual commits):

  1. Group PodcastDB duplicates by postId (matching FeedDB); prefer a favorited/read copy over a merely-newer one.
  2. Extend the same protection to podcast playback progress (current), which has no read equivalent but is exposed to the identical race.
  3. Replace the boolean OR-merge with per-field authority timestamps (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 plain modifiedAt recency, then merges favorite/read/current independently 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-swiftdata skill that @Model silently ignores didSet/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 build succeeds (iPhone 17 Pro Max simulator — iPhone 17 Pro is not installed on this machine)
  • xcodebuild test -testPlan MacMagazine — 108 tests, 0 failures
  • swiftlint lint --strict — 0 violations across all changed files
  • New regression coverage: favorite-over-recency, explicit cross-device unfavorite, read-only merge, field independence, podcast progress merge (spurious duplicate, deliberate rewind, favorite+progress independence), toggleFavorite/toggleRead/markAsRead/updateProgress timestamp isolation

Co-Authored-By: Claude noreply@anthropic.com

cassio-rossi and others added 4 commits July 29, 2026 23:37
…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>
@cassio-rossi

Copy link
Copy Markdown
Collaborator Author

Automated code review (Claude Code)

🔴 Correctness

1. NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift:233
The primary article-read path (.task on the news detail sheet) sets read/modifiedAt directly instead of calling the new markAsRead(), so readModifiedAt never advances.

User opens an article on Device A (read = true but readModifiedAt stays .distantPast). A stale synced duplicate with any readModifiedAt later arrives from iCloud/refresh and out-ranks it in FeedDB.deduplicate()'s per-field merge (FeedDB.swift:131), silently reverting the article to unread — the exact class of bug this PR claims to fix, reintroduced via the main reading flow.

2. MacMagazine/Features/News/DeepLinkNewsDetailView.swift:60
The favorite toggle in the deep-link article detail view mutates favorite/modifiedAt directly instead of calling toggleFavorite(), leaving favoriteModifiedAt stale.

User taps a push-notification deep link and favorites the article here; favoriteModifiedAt never updates from .distantPast. If a duplicate record exists with any later favoriteModifiedAt, deduplicate() (FeedDB.swift:127) will pick the other record's favorite value, silently discarding the favorite set from this screen.

3. MacMagazine/Features/News/DeepLinkNewsDetailView.swift:47
.onAppear in the deep-link detail view sets read/modifiedAt directly instead of calling markAsRead() — same bypass as the main NewsView flow.

Opening an article via push notification marks it read without stamping readModifiedAt; a later deduplicate() pass can revert read to false if a duplicate carries a newer readModifiedAt, silently losing read state for anything opened via deep link.

4. FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift:37
The designated initializer has a parameter typo ead: Bool = false (missing leading r); the body's self.read = read resolves read to the implicit self.read (self-assignment), so the ead: argument is silently discarded and read can never be set to true through the initializer.

Verified by compiling a minimal @Model reproduction: Foo(ead: true) followed by reading f.read yields false with zero compiler diagnostic once @Model is applied. Correctly-labeled calls (read:) won't compile at all; the misleadingly-correct ead: label silently no-ops — a latent trap sitting directly inside the exact initializer this PR extended.

5. MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift:21
ModelPrioritizable.latest() uses group.max(by:), whose last-wins tie-break makes the merge non-deterministic when two duplicates share the exact same authority timestamp (e.g. both still at .distantPast).

Two duplicate PodcastDB rows both have progressModifiedAt == .distantPast but different current values. latest()'s tie-break depends on Dictionary(grouping:).values iteration order, which is unspecified — the merge outcome can differ between app launches/devices for the same data, contradicting the PR's goal of deterministic, authority-based resolution.

🟡 Duplication

6. FeedLibrary/Sources/FeedLibrary/Database/PodcastDB.swift:88
FeedDB.deduplicate() and PodcastDB.deduplicate() are now near-identical ~15-line bodies (fetch → group by postId → max-modifiedAt survivor → two latest() field merges → delete non-survivors → save), duplicated instead of shared, even though the comparison primitive (ModelPrioritizable.latest) already lives in shared MacMagazineLibrary.

A future fix to the merge/survivor logic must be applied and tested twice, in two files, with no compiler tie between them.

⚪ Style

7. FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift:85
New public API (toggleFavorite(), toggleRead(), markAsRead() on FeedDB; toggleFavorite(), updateProgress() on PodcastDB; ModelPrioritizable.latest()) has no /// DocC comments, per CLAUDE.md's explicit rule.


🤖 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>
@cassio-rossi
cassio-rossi merged commit abb5641 into release/v5 Jul 29, 2026
2 checks passed
@cassio-rossi
cassio-rossi deleted the fix/304-favorite-lost-on-dedup branch July 29, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Favoritos se perdendo em updates?

1 participant