crossmate

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

commit 62c87f7137e99bdfd3346578de94b9ed7d4879ff
parent 254f2a35bc9a14807197582d8c1847b359547c74
Author: Michael Camilleri <[email protected]>
Date:   Mon, 27 Jul 2026 11:02:06 +0900

Remove device-side fixes for pre-release data shapes

1.0.0 was the first public build, so the lease and broadcast-invite
pings, stale hails, retired play-event ping kinds, uncached summaries,
pre-watermark read cursors and single-zone friendships these one-shot
passes repaired were all retired before any user could hold them. They
have been inert for every real install since the day they shipped.
Dropping the ping purges also orphaned gameZoneIDs and deleteRecords.

Retiring the read-through backfill retires the legacy branch it fed:
unreadOtherMovesPredicate and computeHasUnread both consulted the
presence lease when readThroughAt was nil, and had to move together or
the badge count and the row dot would disagree. Both now key off the
watermark alone, which matches the old logic on every shape a released
build can produce.

The stranded-row heal and the zone-identity backfill stay, since they
repair rows written by released builds, as does AppDefaultsMigrator —
PuzzleSource.nyt shipped in 1.0.0 and users can still hold that default.

Co-Authored-By: Claude Opus 5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 61+++++++------------------------------------------------------
MCrossmate/Persistence/PersistenceController.swift | 37++-----------------------------------
MCrossmate/Services/AppServices.swift | 19++++++-------------
MCrossmate/Services/BadgeCoordinator.swift | 8--------
MCrossmate/Sync/CloudQuery.swift | 26--------------------------
MCrossmate/Sync/FriendController.swift | 104+------------------------------------------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 210-------------------------------------------------------------------------------
MShared/NotificationState.swift | 131++++---------------------------------------------------------------------------
MTests/Unit/GameStoreUnreadMovesTests.swift | 100++++---------------------------------------------------------------------------
MTests/Unit/NotificationStateTests.swift | 9---------
10 files changed, 26 insertions(+), 679 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -248,11 +248,8 @@ struct GameSummary: Identifiable, Equatable { isShared: self.isShared, latest: entity.latestOtherMoveAt, // The unread badge keys off the read *watermark*, not the presence - // lease. Rows created before the watermark existed only have the - // older cursor, so use a non-future legacy value as a migration - // fallback until the first real readThroughAt write lands. - readThrough: entity.readThroughAt, - legacyPresenceUntil: entity.lastReadOtherMoveAt + // lease (`lastReadOtherMoveAt`). + readThrough: entity.readThroughAt ) } @@ -264,15 +261,11 @@ struct GameSummary: Identifiable, Equatable { fileprivate static func computeHasUnread( isShared: Bool, latest: Date?, - readThrough: Date?, - legacyPresenceUntil: Date? + readThrough: Date? ) -> Bool { guard isShared, let latest else { return false } - if let readThrough { - return latest > readThrough - } - guard let legacyPresenceUntil, legacyPresenceUntil <= Date() else { return true } - return latest > legacyPresenceUntil + guard let readThrough else { return true } + return latest > readThrough } private static func computeParticipants( @@ -1229,54 +1222,14 @@ 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 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 OR latestOtherMoveAt > readThroughAt)" - ) - request.propertiesToFetch = ["id", "latestOtherMoveAt", "readThroughAt"] - let rows = (try? context.fetch(request)) ?? [] - var changed = 0 - for entity in rows { - guard let id = entity.id, - !excludedGameIDs.contains(id), - let latest = entity.latestOtherMoveAt - else { continue } - entity.readThroughAt = latest - changed += 1 - } - guard changed > 0 else { return 0 } - saveContext("backfillLegacyReadThrough") - onUnreadOtherMovesChanged?() - return changed - } - private var unreadOtherMovesPredicate: NSPredicate { // Keyed off the read *watermark* (`readThroughAt`), not the forward- // dated presence lease (`lastReadOtherMoveAt`) — matches - // `GameSummary.computeHasUnread`. For pre-watermark rows, fall back to - // a non-future legacy cursor so an upgrade doesn't badge every - // already-seen shared puzzle whose readThroughAt starts nil. Future - // legacy values are active-session leases, not durable read watermarks. + // `GameSummary.computeHasUnread`. NSPredicate( format: "(databaseScope == 1 OR ckShareRecordName != nil) " + "AND latestOtherMoveAt != nil " - + "AND (" - + " (readThroughAt != nil AND latestOtherMoveAt > readThroughAt) " - + " OR (readThroughAt == nil AND " - + " (lastReadOtherMoveAt == nil " - + " OR lastReadOtherMoveAt > %@ " - + " OR latestOtherMoveAt > lastReadOtherMoveAt))" - + ")", - Date() as NSDate + + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" ) } diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -62,12 +62,11 @@ final class PersistenceController { container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump if !inMemory { - // Synchronous, unlike the backfills below: a stranded row shadows + // Synchronous, unlike the backfill below: a stranded row shadows // the real game in every `id`-keyed lookup, so leaving a window // where the library is live but the heal hasn't landed means the // user can still open the broken row on this launch. healStrandedSharedGameRows_v1() - backfillCachedSummaryFields() backfillZoneIdentityFields() } } @@ -289,39 +288,7 @@ final class PersistenceController { } } - // MARK: - Backfills - - /// One-shot pass for `GameEntity` rows created before cached summary - /// fields were wired into the creation paths. Runs off the main thread, - /// no-ops on every subsequent launch. - private func backfillCachedSummaryFields() { - let bg = container.newBackgroundContext() - bg.perform { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@", - "" - ) - guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } - for entity in rows { - guard let source = entity.puzzleSource, - let xd = try? XD.parse(source) else { continue } - entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) - } - if bg.hasChanges { - do { - try bg.save() - } catch { - Task { @MainActor [weak self] in - self?.eventLog?.note( - "PersistenceController: backfillCachedSummaryFields save failed — \(error)", - level: "error" - ) - } - } - } - } - } + // MARK: - Backfill /// One-shot pass for `GameEntity` rows written before inbound lookups /// matched on full zone identity (`RecordSerializer.gameIdentityPredicate`). diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1394,21 +1394,14 @@ final class AppServices { syncMonitor.note("iCloud sync disabled — engine startup skipped") return } - // One-shot migration of pre-existing single-zone friendships to the - // two-mailbox model. Runs only once the engine is up (it enqueues zone - // creates, a share, and a bootstrap ping) and only with sync enabled. - // Detached so it never delays the foreground sync below. + // Re-announce any inbox share a friend has still not accepted. The + // bootstrap Ping is otherwise one-shot: a friend whose accept failed at + // delivery is stuck at `friendshipNotReady` with no other retry path. + // Runs only once the engine is up (it enqueues a share and a bootstrap + // ping) and only with sync enabled. Detached so it never delays the + // foreground sync below. if let localAuthorID = identity.currentID, !localAuthorID.isEmpty { Task { [friendController, preferences] in - await friendController.migrateToMailboxes( - localAuthorID: localAuthorID, - localDisplayName: preferences.name - ) - // Re-announce any inbox share a friend has still not accepted. - // The bootstrap Ping is otherwise one-shot: a friend whose - // accept failed at delivery is stuck at `friendshipNotReady` - // with no other retry path. Sequenced after the migration so - // the two never mutate the same pair's share concurrently. await friendController.healPendingBootstraps( localAuthorID: localAuthorID, localDisplayName: preferences.name diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift @@ -109,14 +109,6 @@ final class BadgeCoordinator { /// newer `seenAt` and won't resurrect — which the old set-based ledger /// couldn't express, hence why this write-back was previously dropped. func refreshAppBadge(reason: String = "unspecified") async { - if BadgeState.claimLegacyReadThroughHealNeeded() { - let deliveredUnread = await deliveredUnreadGameIDs() - let healed = store.backfillLegacyReadThrough(excluding: deliveredUnread) - syncMonitor.note( - "app badge legacy readThrough heal: changed=\(healed) " - + "preservedDelivered=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" - ) - } let coreDataUnread = store.unreadOtherMovesGameTimes() BadgeState.seedUnread(coreDataUnread) // Pending invites are binary (not a read horizon), so publish them as a diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -1126,32 +1126,6 @@ extension SyncEngine { liveQueryCheckpoints["\(scopeValue.rawValue):\(gameID.uuidString)"] = date } - func deleteRecords( - withIDs recordIDs: [CKRecord.ID], - in database: CKDatabase - ) async throws { - guard !recordIDs.isEmpty else { return } - let batchSize = 200 - var index = recordIDs.startIndex - while index < recordIDs.endIndex { - let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex) - ?? recordIDs.endIndex - let batch = Array(recordIDs[index..<end]) - try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in - let op = CKModifyRecordsOperation( - recordsToSave: nil, - recordIDsToDelete: batch - ) - op.qualityOfService = .utility - op.modifyRecordsResultBlock = { result in - cont.resume(with: result) - } - database.add(op) - } - index = end - } - } - /// Fetches every device's uploaded `Journal` record for a finished game, /// together with the set of devices that wrote grid state (from the `Moves` /// record names), so the caller can gate replay on completeness. A plain diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -313,7 +313,7 @@ final class FriendController { zoneID: FriendZone.inboxZoneID(pairKey: pair.pairKey) ) else { // Nothing to announce: the inbox share was never created. - // `establish`/`migrateToMailboxes` own that repair. + // `establish` owns that repair. skipped += 1 continue } @@ -401,108 +401,6 @@ final class FriendController { return (try? ctx.fetch(req).first)?.game?.id } - // MARK: - Migration - - /// One-shot migration of pre-mailbox friendships (a single elected-owner - /// shared zone) to the two-mailbox model. The old shared zone already - /// serves as the elected owner's inbox; the old participant must stand up - /// their own inbox and announce it. The announcement rides the old zone - /// itself (both sides retain read-write access), so no shared game is - /// needed — the owner side auto-accepts via `applyFriendPing` on its next - /// sync. Idempotent: the per-pair `inboxEstablished` marker and the - /// existing-share check make a re-run a no-op, and a device-level - /// `NotificationState` flag skips it entirely after the first full success. - func migrateToMailboxes(localAuthorID: String, localDisplayName: String?) async { - guard NotificationState.friendMailboxMigrationNeeded(), - !localAuthorID.isEmpty - else { return } - - let ctx = persistence.container.newBackgroundContext() - let pairs: [(pairKey: String, authorID: String)] = await ctx.perform { - let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - return ((try? ctx.fetch(req)) ?? []).compactMap { friend in - guard let pairKey = friend.pairKey, let authorID = friend.authorID - else { return nil } - return (pairKey, authorID) - } - } - - var allSucceeded = true - for (pairKey, friendAuthorID) in pairs where !FriendZone.inboxEstablished(pairKey: pairKey) { - let inboxZoneID = FriendZone.inboxZoneID(pairKey: pairKey) - syncMonitor?.recordStart("migrate friendship") - do { - try await createZone(inboxZoneID) - - if try await existingZoneWideShare(zoneID: inboxZoneID) != nil { - // We were the elected owner — the old shared zone is already - // our inbox, with the friend joined as a participant. Nothing - // to announce; we accept *their* new inbox when their - // announcement arrives via `applyFriendPing`. - FriendZone.markInboxEstablished(pairKey: pairKey) - syncMonitor?.recordSuccess("migrate friendship") - continue - } - - // We were the participant: stand up our new inbox, share it to - // the friend, and announce it by writing a `.friend` bootstrap - // Ping into the old zone — our outbox (the friend's inbox), where - // we still have write access. Our outbox is that old zone, which - // we already joined, so mark it accepted. - let share = try await saveZoneWideShare( - zoneID: inboxZoneID, - addingParticipant: friendAuthorID - ) - guard let url = share.url, - let encoded = FriendZone.BootstrapPayload( - friendShareURL: url.absoluteString, - pairKey: pairKey, - ownerAuthorID: localAuthorID - ).encodedString() - else { - allSucceeded = false - syncMonitor?.recordError("migrate friendship", FriendError.missingShareURL) - continue - } - FriendZone.markInboxEstablished(pairKey: pairKey) - FriendZone.markOutboxAccepted(pairKey: pairKey) - try await syncEngine.enqueueFriendZonePing( - kind: .friend, - gameID: Self.migrationSentinelGameID(pairKey: pairKey), - gameTitle: "", - authorID: localAuthorID, - playerName: localDisplayName ?? "", - addressee: friendAuthorID, - friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), - friendZoneScope: .shared, - payload: encoded - ) - syncMonitor?.recordSuccess("migrate friendship") - } catch { - allSucceeded = false - syncMonitor?.recordError("migrate friendship", error) - } - } - - // Only burn the one-shot flag once every pair is converted, so a - // transient CloudKit failure retries on the next launch. - if allSucceeded { - NotificationState.markFriendMailboxMigrated() - } - } - - /// Deterministic synthetic game ID for a migration bootstrap Ping. The - /// `.friend` Ping record name is keyed by a game UUID, but a migration has - /// no game; `applyFriendPing` ignores it and reads the pairKey from the - /// payload. Derived from the (64-hex-char) pairKey so it is stable per pair. - private static func migrationSentinelGameID(pairKey: String) -> UUID { - let hex = String(pairKey.prefix(32)) - guard hex.count == 32 else { return UUID() } - let s = Array(hex) - let formatted = "\(String(s[0..<8]))-\(String(s[8..<12]))-\(String(s[12..<16]))-\(String(s[16..<20]))-\(String(s[20..<32]))" - return UUID(uuidString: formatted) ?? UUID() - } - // MARK: - Re-invite /// Writes an `.invite` Ping carrying the game's share URL into the friend diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -504,10 +504,6 @@ actor SyncEngine { // to its periodic poll. Create the database subscriptions ourselves; // CKDatabase.save is idempotent for an existing subscriptionID. Task { await ensureDatabaseSubscriptions() } - Task { await purgeLegacyLeasePings_v1() } - Task { await purgeLegacyInvitePings_v1() } - Task { await purgeStaleHailPings_v1() } - Task { await purgeLegacyPlayPings_v1() } } private func ensureDatabaseSubscriptions() async { @@ -1040,212 +1036,6 @@ actor SyncEngine { return true } - /// Registers an `.opened` Ping for cross-device notification dismissal. - /// Written to the account zone in the private database, so only the - /// authoring user's own devices receive it. The receive side filters - /// self-sends by (authorID, deviceID). - /// One-shot cleanup of legacy `.opened`/`.closed` lease pings from this - /// device's slot in the private-DB account zone. The lease subsystem is - /// gone (cross-device read state now rides `Player.presenceUntil`), so any - /// remaining records are dead weight — every device cleans its own slice - /// the first time it launches the new build. Gated by an App-Group flag; - /// failures retry on the next launch. Slated for removal in a future - /// release once every device of every user has run it. - func purgeLegacyLeasePings_v1() async { - guard NotificationState.legacyLeasePurgeNeeded() else { return } - let predicate = NSPredicate( - format: "kind IN %@ AND deviceID == %@", - ["opened", "closed"], - RecordSerializer.localDeviceID - ) - do { - let records = try await queryRecords( - type: "Ping", - database: container.privateCloudDatabase, - zoneID: RecordSerializer.accountZoneID, - predicate: predicate, - desiredKeys: [] - ) - try await deleteRecords( - withIDs: records.map(\.recordID), - in: container.privateCloudDatabase - ) - NotificationState.markLegacyLeasePurged() - if !records.isEmpty { - await trace("legacy-lease purge: deleted \(records.count) record(s)") - } - } catch { - await trace("legacy-lease purge failed: \(describe(error))") - } - } - - /// One-shot cleanup of legacy broadcast `.invite` pings from pairwise - /// friend zones. Current invites are addressed with `addressee`; older - /// ones were broadcast within the friend zone and can resurrect stale - /// Game List rows after a device restores or replays zone history. - func purgeLegacyInvitePings_v1() async { - guard NotificationState.legacyInvitePurgeNeeded() else { return } - let privateZones = friendZoneIDs(forScope: .private) - let sharedZones = friendZoneIDs(forScope: .shared) - do { - let privateDeleted = try await purgeLegacyInvitePings( - in: privateZones, - database: container.privateCloudDatabase - ) - let sharedDeleted = try await purgeLegacyInvitePings( - in: sharedZones, - database: container.sharedCloudDatabase - ) - NotificationState.markLegacyInvitePurged() - let total = privateDeleted + sharedDeleted - if total > 0 { - await trace("legacy-invite purge: deleted \(total) record(s)") - } - } catch { - await trace("legacy-invite purge failed: \(describe(error))") - } - } - - private func purgeLegacyInvitePings( - in zoneIDs: [CKRecordZone.ID], - database: CKDatabase - ) async throws -> Int { - var deleted = 0 - let predicate = NSPredicate(format: "kind == %@", PingKind.invite.rawValue) - for zoneID in zoneIDs { - let records = try await queryRecords( - type: "Ping", - database: database, - zoneID: zoneID, - predicate: predicate, - desiredKeys: ["addressee"] - ) - let legacyIDs = records - .filter { ($0["addressee"] as? String)?.isEmpty != false } - .map(\.recordID) - try await deleteRecords(withIDs: legacyIDs, in: database) - deleted += legacyIDs.count - } - return deleted - } - - /// One-shot cleanup of stale legacy `.hail` records from known game - /// zones. Hails are ephemeral bootstrap envelopes; any records written - /// before the current cleanup/deletion path shipped can only replay - /// obsolete handshakes, so remove them once per device. - func purgeStaleHailPings_v1() async { - guard NotificationState.staleHailPurgeNeeded() else { return } - do { - let privateDeleted = try await purgeStaleHailPings( - in: gameZoneIDs(forScope: .private), - database: container.privateCloudDatabase - ) - let sharedDeleted = try await purgeStaleHailPings( - in: gameZoneIDs(forScope: .shared), - database: container.sharedCloudDatabase - ) - NotificationState.markStaleHailPurged() - let total = privateDeleted + sharedDeleted - await trace("stale-hail purge: deleted \(total) record(s)") - } catch { - await trace("stale-hail purge failed: \(describe(error))") - } - } - - private func purgeStaleHailPings( - in zoneIDs: [CKRecordZone.ID], - database: CKDatabase - ) async throws -> Int { - var deleted = 0 - let predicate = NSPredicate(format: "kind == %@", PingKind.hail.rawValue) - for zoneID in zoneIDs { - let records = try await queryRecords( - type: "Ping", - database: database, - zoneID: zoneID, - predicate: predicate, - desiredKeys: [] - ) - try await deleteRecords(withIDs: records.map(\.recordID), in: database) - deleted += records.count - } - return deleted - } - - /// One-shot cleanup of Ping records whose kinds were retired when the - /// push worker took over user-facing event notifications: - /// `.check`, `.reveal`, `.resign`, `.win`, and the old session-start - /// `.join`. Current clients no longer write these — `.check`/`.reveal`/ - /// `.resign`/`.win` are gone from `PingKind` entirely, while `.join` - /// remains only so legacy records stay parseable — but records written - /// by pre-cutover builds linger in shared game zones and will replay - /// as obsolete announcements after a zone fetch. Every device drains - /// the game zones it can reach exactly once. - func purgeLegacyPlayPings_v1() async { - guard NotificationState.legacyPlayPingPurgeNeeded() else { return } - do { - let privateDeleted = try await purgeLegacyPlayPings( - in: gameZoneIDs(forScope: .private), - database: container.privateCloudDatabase - ) - let sharedDeleted = try await purgeLegacyPlayPings( - in: gameZoneIDs(forScope: .shared), - database: container.sharedCloudDatabase - ) - NotificationState.markLegacyPlayPingPurged() - let total = privateDeleted + sharedDeleted - if total > 0 { - await trace("legacy play-ping purge: deleted \(total) record(s)") - } - } catch { - await trace("legacy play-ping purge failed: \(describe(error))") - } - } - - private func purgeLegacyPlayPings( - in zoneIDs: [CKRecordZone.ID], - database: CKDatabase - ) async throws -> Int { - var deleted = 0 - let predicate = NSPredicate( - format: "kind IN %@", - ["check", "reveal", "resign", "win", "join"] - ) - for zoneID in zoneIDs { - let records = try await queryRecords( - type: "Ping", - database: database, - zoneID: zoneID, - predicate: predicate, - desiredKeys: [] - ) - try await deleteRecords(withIDs: records.map(\.recordID), in: database) - deleted += records.count - } - return deleted - } - - private func gameZoneIDs(forScope scope: DatabaseScope) async -> [CKRecordZone.ID] { - let ctx = persistence.container.newBackgroundContext() - return await ctx.perform { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND isAccessRevoked == NO", - scope.rawValue - ) - var seen = Set<String>() - var result: [CKRecordZone.ID] = [] - for entity in (try? ctx.fetch(req)) ?? [] { - guard let gameID = entity.id else { continue } - let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" - let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName - let key = "\(ownerName)|\(zoneName)" - guard seen.insert(key).inserted else { continue } - result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) - } - return result - } - } /// Registers a directed Ping (`.invite` or `.decline`) into an existing /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -101,90 +101,6 @@ enum NotificationState { isActive(gameID: gameID, now: now) } - private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1" - private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.v1" - private static let staleHailPurgeKey = "migration.staleHailPurge.v1" - private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1" - private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1" - private static let friendMailboxMigrationKey = "migration.friendMailboxes.v1" - - /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings - /// has not yet run successfully on this device. The flag is per-device - /// (App Group UserDefaults), so each device drains its own slice of the - /// account zone exactly once. - static func legacyLeasePurgeNeeded() -> Bool { - defaults?.bool(forKey: legacyLeasePurgeKey) == false - } - - /// Records that the legacy-lease purge completed successfully so the next - /// launch skips it. - static func markLegacyLeasePurged() { - defaults?.set(true, forKey: legacyLeasePurgeKey) - } - - /// True if the one-shot cleanup of legacy broadcast `.invite` pings has - /// not yet run successfully on this device. - static func legacyInvitePurgeNeeded() -> Bool { - defaults?.bool(forKey: legacyInvitePurgeKey) == false - } - - /// Records that the legacy invite purge completed successfully so the next - /// launch skips it. - static func markLegacyInvitePurged() { - defaults?.set(true, forKey: legacyInvitePurgeKey) - } - - /// True if the one-shot cleanup of pre-migration `.hail` pings from game - /// zones has not yet run successfully on this device. - static func staleHailPurgeNeeded() -> Bool { - defaults?.bool(forKey: staleHailPurgeKey) == false - } - - /// Records that the stale-hail purge completed successfully so the next - /// launch skips it. - static func markStaleHailPurged() { - defaults?.set(true, forKey: staleHailPurgeKey) - } - - /// True if the one-shot cleanup of local debug-preview friend rows has - /// not yet run successfully on this device. - static func debugPreviewFriendPurgeNeeded() -> Bool { - defaults?.bool(forKey: debugPreviewFriendPurgeKey) == false - } - - /// Records that the debug-preview friend cleanup completed successfully. - static func markDebugPreviewFriendPurged() { - defaults?.set(true, forKey: debugPreviewFriendPurgeKey) - } - - /// True if the one-shot cleanup of retired play-event Ping kinds - /// (`.check`, `.reveal`, `.resign`, `.win`, and the old session-start - /// `.join`) has not yet run successfully on this device. Those kinds - /// were retired when the push worker took over user-facing event - /// notifications; any records still in game zones are dead weight that - /// can resurrect obsolete announcements on a zone replay. - static func legacyPlayPingPurgeNeeded() -> Bool { - defaults?.bool(forKey: legacyPlayPingPurgeKey) == false - } - - /// Records that the legacy play-ping purge completed successfully. - static func markLegacyPlayPingPurged() { - defaults?.set(true, forKey: legacyPlayPingPurgeKey) - } - - /// True if the one-shot migration of pre-mailbox friendships (a single - /// elected-owner shared zone) to the two-mailbox model has not yet run on - /// this device. - static func friendMailboxMigrationNeeded() -> Bool { - defaults?.bool(forKey: friendMailboxMigrationKey) == false - } - - /// Records that the friend-mailbox migration completed so the next launch - /// skips it. - static func markFriendMailboxMigrated() { - defaults?.set(true, forKey: friendMailboxMigrationKey) - } - /// Exposes the (testing-aware) App Group defaults to siblings in this /// module — currently `BadgeState`, which shares storage but lives in /// its own namespace. @@ -216,11 +132,7 @@ enum NotificationState { /// describes. enum BadgeState { private static let ledgerKey = "badge.ledger.v2" - private static let legacyLedgerKey = "badge.ledger.v1" - private static let legacyUnreadKey = "badge.unreadGameIDs" private static let pendingInvitesKey = "badge.pendingInvites.v1" - private static let legacyReadThroughHealV1Key = "badge.legacyReadThroughHeal.v1" - private static let legacyReadThroughHealKey = "badge.legacyReadThroughHeal.v2" private struct Entry: Codable, Equatable { var unreadAt: Date? = nil @@ -380,51 +292,20 @@ enum BadgeState { return Set(raw.compactMap(UUID.init(uuidString:))) } - /// 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 `presenceUntil`/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 } - defaults.set(true, forKey: legacyReadThroughHealKey) - return true - } - - /// Clears the entire ledger (and any legacy stores). Used by the - /// diagnostics "reset all data" path, which deletes every game at once. + /// Clears the entire ledger. Used by the diagnostics "reset all data" + /// path, which deletes every game at once. static func reset() { guard let defaults else { return } defaults.removeObject(forKey: ledgerKey) - defaults.removeObject(forKey: legacyLedgerKey) - defaults.removeObject(forKey: legacyUnreadKey) defaults.removeObject(forKey: pendingInvitesKey) - defaults.removeObject(forKey: legacyReadThroughHealV1Key) - defaults.removeObject(forKey: legacyReadThroughHealKey) } private static func loadLedger() -> [String: Entry] { guard let defaults else { return [:] } - if let data = defaults.data(forKey: ledgerKey), - let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) { - return ledger - } - // Neither the pre-horizon set (`badge.unreadGameIDs`) nor the v1 ledger - // carried trustworthy unread timestamps we can rebuild from: the v1 - // migration fabricated `unreadAt = now` for every legacy entry, which - // makes an already-seen game look freshly unread and, worse, - // un-clearable by read state — a future-dated `unreadAt` beats any past - // `seenAt`, and a deleted game has nothing left to open. Core Data is - // the durable ground truth for unread-other-moves, so discard both - // legacy stores and let `refreshAppBadge` re-seed from Core Data on the - // next run rather than carry phantom badges forward. - if defaults.object(forKey: legacyLedgerKey) != nil { - defaults.removeObject(forKey: legacyLedgerKey) - } - if defaults.object(forKey: legacyUnreadKey) != nil { - defaults.removeObject(forKey: legacyUnreadKey) - } - return [:] + guard let data = defaults.data(forKey: ledgerKey), + let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) + else { return [:] } + return ledger } private static func saveLedger(_ ledger: [String: Entry]) { diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -383,27 +383,10 @@ struct GameStoreUnreadMovesTests { #expect(!store.hasUnreadOtherMoves(gameID: gameID)) } - @Test("Legacy read cursor backs pre-watermark rows") - func legacyReadCursorBacksPreWatermarkRows() throws { - let persistence = makeTestPersistence() - let store = makeTestStore(persistence: persistence) - let ctx = persistence.viewContext - let (entity, gameID) = try makeSharedGame(in: ctx) - - let latest = Date(timeIntervalSinceNow: -60) - entity.latestOtherMoveAt = latest - entity.lastReadOtherMoveAt = latest - entity.readThroughAt = nil - try ctx.save() - - let summary = try #require(GameSummary(entity: entity)) - #expect(!summary.hasUnreadOtherMoves) - #expect(store.unreadOtherMovesGameCount() == 0) - #expect(!store.hasUnreadOtherMoves(gameID: gameID)) - } - - @Test("Read watermark overrides a newer legacy presence lease") - func readWatermarkOverridesNewerLegacyPresenceLease() throws { + /// Checks the summary and the store predicate agree: both must key off the + /// watermark alone, so a forward-dated presence lease cannot clear unread. + @Test("Read watermark overrides a newer presence lease") + func readWatermarkOverridesNewerPresenceLease() throws { let persistence = makeTestPersistence() let store = makeTestStore(persistence: persistence) let ctx = persistence.viewContext @@ -422,81 +405,6 @@ struct GameStoreUnreadMovesTests { #expect(store.hasUnreadOtherMoves(gameID: gameID)) } - @Test("Legacy read-through backfill clears phantom pre-watermark unread rows") - func legacyReadThroughBackfillClearsPhantomRows() throws { - let persistence = makeTestPersistence() - let store = makeTestStore(persistence: persistence) - let ctx = persistence.viewContext - let (entity, gameID) = try makeSharedGame(in: ctx) - - let latest = Date(timeIntervalSinceNow: -60) - entity.latestOtherMoveAt = latest - entity.readThroughAt = nil - entity.lastReadOtherMoveAt = nil - 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 unread games") - func legacyReadThroughBackfillPreservesDeliveredUnread() throws { - let persistence = makeTestPersistence() - let store = makeTestStore(persistence: persistence) - let ctx = persistence.viewContext - let (entity, gameID) = try makeSharedGame(in: ctx) - - entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60) - entity.readThroughAt = nil - try ctx.save() - - #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0) - #expect(entity.readThroughAt == nil) - #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() diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -272,13 +272,4 @@ struct NotificationStateTests { #expect(BadgeState.unreadGameIDs().isEmpty) #expect(BadgeState.pendingInviteGameIDs().isEmpty) } - - @Test("Legacy read-through heal is claimed once") - func legacyReadThroughHealClaimedOnce() { - #expect(BadgeState.claimLegacyReadThroughHealNeeded()) - #expect(!BadgeState.claimLegacyReadThroughHealNeeded()) - - BadgeState.reset() - #expect(BadgeState.claimLegacyReadThroughHealNeeded()) - } }