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:
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()