crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 82d7c9f64605e530cfd74a5f3513471534f998d7
parent a11d8034301264c3ac2e352cf27a597530590cea
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 19:09:45 +0900

Heal legacy games that are still erroneously unread

Diffstat:
MCrossmate/Persistence/GameStore.swift | 8++++----
MShared/NotificationState.swift | 7+++++--
MTests/Unit/GameStoreUnreadMovesTests.swift | 38++++++++++++++++++++++++++++++++++++++
3 files changed, 47 insertions(+), 6 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -843,16 +843,16 @@ final class GameStore { /// One-shot upgrade heal for the read-watermark split. Older installs only /// had `lastReadOtherMoveAt`, which has since become a presence lease; rows - /// with nil `readThroughAt` can therefore all look unread after upgrading. - /// Backfill them to their current latest peer move unless a delivered - /// unread notification still represents that game. + /// with missing or stale `readThroughAt` can therefore look unread after + /// upgrading. Backfill them to their current latest peer move unless a + /// delivered unread notification still represents that game. @discardableResult func backfillLegacyReadThrough(excluding excludedGameIDs: Set<UUID>) -> Int { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate( format: "(databaseScope == 1 OR ckShareRecordName != nil) " + "AND latestOtherMoveAt != nil " - + "AND readThroughAt == nil" + + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" ) request.propertiesToFetch = ["id", "latestOtherMoveAt", "readThroughAt"] let rows = (try? context.fetch(request)) ?? [] diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -205,7 +205,8 @@ enum BadgeState { private static let legacyLedgerKey = "badge.ledger.v1" private static let legacyUnreadKey = "badge.unreadGameIDs" private static let pendingInvitesKey = "badge.pendingInvites.v1" - private static let legacyReadThroughHealKey = "badge.legacyReadThroughHeal.v1" + private static let legacyReadThroughHealV1Key = "badge.legacyReadThroughHeal.v1" + private static let legacyReadThroughHealKey = "badge.legacyReadThroughHeal.v2" private struct Entry: Codable, Equatable { var unreadAt: Date? = nil @@ -367,7 +368,8 @@ enum BadgeState { /// Returns true once per install after the read-watermark split, so the app /// can backfill `Game.readThroughAt` for pre-split rows that only carried - /// the older `readAt`/presence cursor. The NSE never calls this. + /// the older `readAt`/presence cursor, and repair stale first-pass + /// watermarks. The NSE never calls this. static func claimLegacyReadThroughHealNeeded() -> Bool { guard let defaults else { return false } guard defaults.bool(forKey: legacyReadThroughHealKey) == false else { return false } @@ -383,6 +385,7 @@ enum BadgeState { defaults.removeObject(forKey: legacyLedgerKey) defaults.removeObject(forKey: legacyUnreadKey) defaults.removeObject(forKey: pendingInvitesKey) + defaults.removeObject(forKey: legacyReadThroughHealV1Key) defaults.removeObject(forKey: legacyReadThroughHealKey) } diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -459,6 +459,44 @@ struct GameStoreUnreadMovesTests { #expect(store.unreadOtherMovesGameCount() == 1) } + @Test("Legacy read-through backfill advances stale watermarks") + func legacyReadThroughBackfillAdvancesStaleWatermarks() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try makeSharedGame(in: ctx) + + let readThrough = Date(timeIntervalSinceNow: -120) + let latest = Date(timeIntervalSinceNow: -60) + entity.latestOtherMoveAt = latest + entity.readThroughAt = readThrough + try ctx.save() + + #expect(store.unreadOtherMovesGameCount() == 1) + + #expect(store.backfillLegacyReadThrough(excluding: []) == 1) + #expect(entity.readThroughAt == latest) + #expect(store.unreadOtherMovesGameCount() == 0) + #expect(!store.hasUnreadOtherMoves(gameID: gameID)) + } + + @Test("Legacy read-through backfill preserves delivered stale unread games") + func legacyReadThroughBackfillPreservesDeliveredStaleUnread() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try makeSharedGame(in: ctx) + + let readThrough = Date(timeIntervalSinceNow: -120) + entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60) + entity.readThroughAt = readThrough + try ctx.save() + + #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0) + #expect(entity.readThroughAt == readThrough) + #expect(store.unreadOtherMovesGameCount() == 1) + } + @Test("Active read leases refresh only when the horizon is below the floor") func activeReadLeaseRefreshesAtFloor() throws { let persistence = makeTestPersistence()