commit 4af80a380caa6af6783ef301d2529898ed340cdf parent 27a742fdf449be0e2636f32b999e612705c363c4 Author: Michael Camilleri <[email protected]> Date: Tue, 7 Jul 2026 14:52:23 +0900 Rename the presence lease from readAt to presenceUntil The forward-dated presence lease was renamed to presenceUntil on the wire in the v4 schema, but the Swift parameter, the PlayerEntity Core Data attribute, and the diagnostics all still carried the v3 name readAt — easy to misread as the read cursor, which since the watermark split is the separate readThrough field. The stale name was misleading enough that a log line reading 'cursor ADOPT … readAt=' invited exactly that confusion. This commit renames readAt to presenceUntil throughout and adds a renamingIdentifier so the store migrates lightly, leaving the unrelated unreadAt badge horizon and the readThrough watermark alone. No CloudKit schema change is needed; the wire field is already presenceUntil. The lease diagnostics are relabelled to match. The adoption log and the Player enqueue reasons now read 'lease ADOPT' and 'lease(activeLease)' rather than 'cursor', so a presenceUntil value always sits beside lease wording — consistent with the existing 'lease MINT' and 'open lease snapshot' lines. Co-Authored-By: Claude Opus 4.8 <[email protected]> Diffstat:
28 files changed, 221 insertions(+), 223 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -281,7 +281,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU let gameID = Self.gameID(from: userInfo) let kind = userInfo["kind"] as? String let senderDeviceID = userInfo["senderDeviceID"] as? String - let readAt = Self.date(from: userInfo["readAt"] as? String) + let presenceUntil = Self.date(from: userInfo["presenceUntil"] as? String) let isBackground = application.applicationState != .active await onRemoteNotification?( summary, @@ -290,7 +290,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU gameID, kind, senderDeviceID, - readAt, + presenceUntil, isBackground ) return .newData @@ -938,7 +938,7 @@ private struct PuzzleDisplayView: View { case .background: // Stop the engagement reconnect loop so it doesn't keep // re-dialling the live socket on background CKSyncEngine wakes. - // (Re-leasing `readAt` in the background is now prevented + // (Re-leasing `presenceUntil` in the background is now prevented // centrally by publishReadCursor's foreground gate, not here.) // `.active` re-arms the loop via `startEngagementIfPossible`. services.engagement.cancelEngagementReconnectRetry(gameID: id) diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -55,7 +55,7 @@ <attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="notifiedThrough" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="pushAddress" optional="YES" attributeType="String"/> - <attribute name="readAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="presenceUntil" optional="YES" attributeType="Date" usesScalarValueType="NO" renamingIdentifier="readAt"/> <attribute name="readThrough" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> diff --git a/Crossmate/Models/GameViewedStore.swift b/Crossmate/Models/GameViewedStore.swift @@ -6,7 +6,7 @@ import Foundation /// Device-local by design — it is never synced to CloudKit. It records when /// *this* player last had the board open so that, on reopening, cells a peer /// filled or cleared while they were away can be highlighted. This is unrelated -/// to the synced `Player.readAt`/`notifiedThrough` notification bookkeeping; it +/// to the synced `Player.presenceUntil`/`notifiedThrough` notification bookkeeping; it /// is purely local viewing state, like `GameCursorStore`. /// /// Layout under the `"gameLastViewed"` key: diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -50,7 +50,7 @@ final class PlayerRoster { var nicknamesByAuthor: [String: String] = [:] var moveAuthorIDs: [String] = [] var rawSelections: [RawSelection] = [] - var readAtByAuthor: [String: Date] = [:] + var presenceUntilByAuthor: [String: Date] = [:] var finalSolveSeconds: Int64? var completedAt: Date? var timeLogs: [TimeLog] = [] @@ -62,11 +62,11 @@ final class PlayerRoster { /// present in this map. private var persistedRemoteSelections: [String: RemoteSelection] = [:] - /// Each non-local peer's active-session lease (`Player.readAt`). A cursor + /// Each non-local peer's active-session lease (`Player.presenceUntil`). A cursor /// is shown only while its peer is present (`PeerPresence.isPresent`), so a /// peer who pauses keeps their cursor and a departed peer's cursor clears /// when the lease lapses — the same heuristic that gates engagement. - private var remoteReadAt: [String: Date] = [:] + private var remotePresenceUntil: [String: Date] = [:] private var finalSolveSeconds: Int64? private var completedAt: Date? private var timeLogs: [TimeLog] = [] @@ -98,7 +98,7 @@ final class PlayerRoster { } } let now = Date() - return merged.filter { PeerPresence.isPresent(readAt: remoteReadAt[$0.key], asOf: now) } + return merged.filter { PeerPresence.isPresent(presenceUntil: remotePresenceUntil[$0.key], asOf: now) } } private(set) var entries: [Entry] = [] @@ -190,10 +190,10 @@ final class PlayerRoster { isLocal: false )) self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection] - self.remoteReadAt = [remoteSelection.authorID: Date().addingTimeInterval(600)] + self.remotePresenceUntil = [remoteSelection.authorID: Date().addingTimeInterval(600)] } else { self.persistedRemoteSelections = [:] - self.remoteReadAt = [:] + self.remotePresenceUntil = [:] } self.entries = entries } @@ -248,7 +248,7 @@ final class PlayerRoster { self.localAuthorID = nil entries = [] persistedRemoteSelections = [:] - remoteReadAt = [:] + remotePresenceUntil = [:] finalSolveSeconds = nil completedAt = nil timeLogs = [] @@ -274,7 +274,7 @@ final class PlayerRoster { let nameEntities = (try? ctx.fetch(nameReq)) ?? [] var namesMap: [String: String] = [:] var selections: [RawSelection] = [] - var readAtByAuthor: [String: Date] = [:] + var presenceUntilByAuthor: [String: Date] = [:] var timeLogs: [TimeLog] = [] for nr in nameEntities { timeLogs.append(TimeLog.decode(nr.timeLog)) @@ -283,8 +283,8 @@ final class PlayerRoster { namesMap[aid] = name } if aid == localAuthorID { continue } - if let readAt = nr.readAt { - readAtByAuthor[aid] = readAt + if let presenceUntil = nr.presenceUntil { + presenceUntilByAuthor[aid] = presenceUntil } if let row = nr.selRow, let col = nr.selCol, @@ -327,7 +327,7 @@ final class PlayerRoster { nicknamesByAuthor: nicknamesByAuthor, moveAuthorIDs: authorIDs, rawSelections: selections, - readAtByAuthor: readAtByAuthor, + presenceUntilByAuthor: presenceUntilByAuthor, finalSolveSeconds: entity.finalSolveSeconds?.int64Value, completedAt: entity.completedAt, timeLogs: timeLogs @@ -429,7 +429,7 @@ final class PlayerRoster { ) } persistedRemoteSelections = tracks - remoteReadAt = fetched.readAtByAuthor + remotePresenceUntil = fetched.presenceUntilByAuthor finalSolveSeconds = fetched.finalSolveSeconds completedAt = fetched.completedAt timeLogs = fetched.timeLogs @@ -437,7 +437,7 @@ final class PlayerRoster { } /// Emits a tracer line on each non-local peer's present↔absent edge — the - /// same `readAt`-lease gate that drives their cursor (`remoteSelections`) + /// same `presenceUntil`-lease gate that drives their cursor (`remoteSelections`) /// and the `leaseExpiryTask` recompute. Deduped via `lastPresentAuthors`, /// so the interim and post-share `applyRoster` of one refresh, plus the /// lease-expiry refresh, log a transition only once. A departure prints the @@ -446,8 +446,8 @@ final class PlayerRoster { private func logPresenceTransitions() { guard let tracer else { return } let now = Date() - let present = Set(remoteReadAt.keys.filter { - PeerPresence.isPresent(readAt: remoteReadAt[$0], asOf: now) + let present = Set(remotePresenceUntil.keys.filter { + PeerPresence.isPresent(presenceUntil: remotePresenceUntil[$0], asOf: now) }) guard present != lastPresentAuthors else { return } let nameByAuthor = Dictionary( @@ -461,13 +461,13 @@ final class PlayerRoster { ISO8601DateFormatter().string(from: date) } for authorID in present.subtracting(lastPresentAuthors).sorted() { - let lease = remoteReadAt[authorID] + let lease = remotePresenceUntil[authorID] let until = lease.map(iso) ?? "—" let secs = lease.map { Int($0.timeIntervalSince(now)) } ?? 0 tracer("\(prefix): peer \(describe(authorID)) present (lease until \(until), +\(secs)s)") } for authorID in lastPresentAuthors.subtracting(present).sorted() { - let lease = remoteReadAt[authorID] + let lease = remotePresenceUntil[authorID] let lapsed = lease.map(iso) ?? "—" let ago = lease.map { Int(now.timeIntervalSince($0)) } ?? 0 tracer("\(prefix): peer \(describe(authorID)) no longer present (lease \(lapsed) lapsed \(ago)s ago)") @@ -476,17 +476,17 @@ final class PlayerRoster { } /// Schedules a single recompute at the soonest moment a present peer drops - /// out of presence — its `readAt` plus the presence grace, since a lapsed + /// out of presence — its `presenceUntil` plus the presence grace, since a lapsed /// lease still counts as present through the grace. When it fires, - /// `refresh()` re-reads `readAt` (reassigning `remoteReadAt`, which triggers + /// `refresh()` re-reads `presenceUntil` (reassigning `remotePresenceUntil`, which triggers /// observation so the cursor and engagement icon re-evaluate) and /// reschedules for the next-soonest. No-op when no peer is present. private func scheduleLeaseExpiryRecompute() { leaseExpiryTask?.cancel() leaseExpiryTask = nil let now = Date() - guard let soonest = remoteReadAt.values - .filter({ PeerPresence.isPresent(readAt: $0, asOf: now) }) + guard let soonest = remotePresenceUntil.values + .filter({ PeerPresence.isPresent(presenceUntil: $0, asOf: now) }) .min() else { return } let interval = max(0, soonest.addingTimeInterval(PeerPresence.presenceGrace).timeIntervalSince(now)) leaseExpiryTask = Task { [weak self] in diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -160,7 +160,7 @@ struct GameSummary: Identifiable, Equatable { // older cursor, so use a non-future legacy value as a migration // fallback until the first real readThroughAt write lands. readThrough: entity.readThroughAt, - legacyReadAt: entity.lastReadOtherMoveAt + legacyPresenceUntil: entity.lastReadOtherMoveAt ) } @@ -173,14 +173,14 @@ struct GameSummary: Identifiable, Equatable { isShared: Bool, latest: Date?, readThrough: Date?, - legacyReadAt: Date? + legacyPresenceUntil: Date? ) -> Bool { guard isShared, let latest else { return false } if let readThrough { return latest > readThrough } - guard let legacyReadAt, legacyReadAt <= Date() else { return true } - return latest > legacyReadAt + guard let legacyPresenceUntil, legacyPresenceUntil <= Date() else { return true } + return latest > legacyPresenceUntil } private static func computeParticipants( @@ -1748,7 +1748,7 @@ final class GameStore { return try context.fetch(request).first } - /// Applies this account's `Player.readAt` from a sibling device under + /// Applies this account's `Player.presenceUntil` from a sibling device under /// last-writer-wins: SyncEngine has already accepted this record as the /// freshest server version, so adopt the value directly as the account's /// resolved read horizon. A leaving sibling can pull the horizon back below @@ -1763,15 +1763,15 @@ final class GameStore { /// adoption — cross-device cursor convergence is otherwise invisible in /// the device log. @discardableResult - func noteIncomingReadCursor(gameID: UUID, readAt: Date) -> (previous: Date?, adopted: Bool) { + func noteIncomingReadCursor(gameID: UUID, presenceUntil: Date) -> (previous: Date?, adopted: Bool) { let previous = fetchGameEntity(id: gameID)?.lastReadOtherMoveAt - let adopted = setReadCursor(gameID: gameID, readAt: readAt) + let adopted = setReadCursor(gameID: gameID, presenceUntil: presenceUntil) return (previous, adopted) } /// Sets the per-account **presence lease** for `gameID` — the forward-dated /// "the user is actively present on this puzzle" horizon stored in - /// `lastReadOtherMoveAt`. When `minimumExistingReadAt` is provided, the + /// `lastReadOtherMoveAt`. When `minimumExistingPresenceUntil` is provided, the /// write is skipped if the current lease already reaches that floor; active /// sessions use this to refresh a future lease only when it is close to /// expiry. @@ -1787,8 +1787,8 @@ final class GameStore { @discardableResult func setReadCursor( gameID: UUID, - readAt: Date, - minimumExistingReadAt: Date? = nil + presenceUntil: Date, + minimumExistingPresenceUntil: Date? = nil ) -> Bool { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -1796,14 +1796,14 @@ final class GameStore { guard let entity = try? context.fetch(request).first else { return false } let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 guard isShared else { return false } - if let minimumExistingReadAt, + if let minimumExistingPresenceUntil, let current = entity.lastReadOtherMoveAt, - current >= minimumExistingReadAt { + current >= minimumExistingPresenceUntil { return false } - guard entity.lastReadOtherMoveAt != readAt else { return false } - entity.lastReadOtherMoveAt = readAt - saveContext("updateReadAt") + guard entity.lastReadOtherMoveAt != presenceUntil else { return false } + entity.lastReadOtherMoveAt = presenceUntil + saveContext("updatePresenceUntil") onUnreadOtherMovesChanged?() return true } @@ -2066,7 +2066,7 @@ final class GameStore { /// ships on the Player-record write the puzzle-open burst is already making. /// Returns the derived address, or `nil` if the GameEntity is missing. /// Creating the row here is safe because the open burst fills its `name` and - /// `readAt` before the send — unlike the standalone registration sweep + /// `presenceUntil` before the send — unlike the standalone registration sweep /// (`reconcileLocalPushAddresses`), which must never fabricate a bare row. @discardableResult func setPushAddress(gameID: UUID, authorID: String, secret: String) -> String? { @@ -2105,7 +2105,7 @@ final class GameStore { /// game address under its `credID`. Also returns the games whose existing /// Player row was updated to a new derived address, so the caller can /// republish those rows for peers. The address write-back never fabricates a - /// bare Player row (which would clobber `name`/`readAt` server-side); a game + /// bare Player row (which would clobber `name`/`presenceUntil` server-side); a game /// with no local row still contributes its binding to the registration set. func reconcileLocalPushAddresses( authorID: String, @@ -2177,7 +2177,7 @@ final class GameStore { /// Sender-local "notified through" watermark for `(gameID, authorID)` — /// the latest authored move we've told this recipient about via a pause, - /// or `nil` if we never have. Paired with `Player.readAt` to window the + /// or `nil` if we never have. Paired with `Player.presenceUntil` to window the /// next session-end diff (see `SessionPushPlanner.sessionEndAddressees`). func notifiedThrough(for gameID: UUID, by authorID: String) -> Date? { fetchPlayerEntity(gameID: gameID, authorID: authorID)?.notifiedThrough @@ -2211,7 +2211,7 @@ final class GameStore { /// usual LWW merge across the author's devices, including cleared cells /// (empty `letter` with a non-default `updatedAt`). The pause-push /// per-recipient diff iterates this list, counting cells whose - /// `updatedAt` is newer than that recipient's last-known `Player.readAt`. + /// `updatedAt` is newer than that recipient's last-known `Player.presenceUntil`. func mergedAuthorCells(for gameID: UUID, by authorID: String) -> [TimestampedCell] { let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") request.predicate = NSPredicate( @@ -2428,8 +2428,8 @@ final class GameStore { /// exists or none has been stamped. Used by the local pause-diagnostics /// mirror to compare this device's actual cursor against the value a peer's /// pushed diagnostics claim it saw. - func readAt(for gameID: UUID, by authorID: String) -> Date? { - return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.readAt + func presenceUntil(for gameID: UUID, by authorID: String) -> Date? { + return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.presenceUntil } /// The local author's own derived push address for `gameID`, read off the diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -35,7 +35,7 @@ final class AccountPushCoordinator { private var preferenceObservationTask: Task<Void, Never>? private var preferenceDebounceTask: Task<Void, Never>? - private var lastAccountSeenReadAt: [UUID: Date] = [:] + private var lastAccountSeenPresenceUntil: [UUID: Date] = [:] /// Monotonic stamp for in-flight registration reconciles. A reconcile /// snapshots store state, then suspends (player republish, friend-key /// fetch) before handing the address set to the push client; overlapping @@ -535,7 +535,7 @@ final class AccountPushCoordinator { ) } - func publishAccountSeenPush(gameID: UUID, readAt: Date) async { + func publishAccountSeenPush(gameID: UUID, presenceUntil: Date) async { guard let pushClient else { syncMonitor.note("push(\(Self.accountSeenPushKind)): skipped (no pushClient)") return @@ -544,30 +544,30 @@ final class AccountPushCoordinator { syncMonitor.note("push(\(Self.accountSeenPushKind)): skipped (no authorID)") return } - if shouldCoalesceAccountSeen(gameID: gameID, readAt: readAt) { + if shouldCoalesceAccountSeen(gameID: gameID, presenceUntil: presenceUntil) { syncMonitor.note( "push(accountSeen): coalesced \(gameID.uuidString.prefix(8)) " + - "readAt=\(readAt.ISO8601Format())" + "presenceUntil=\(presenceUntil.ISO8601Format())" ) return } let address = ensureAccountPushAddress(authorID: authorID) - lastAccountSeenReadAt[gameID] = readAt + lastAccountSeenPresenceUntil[gameID] = presenceUntil await pushClient.publishAccountEvent( kind: Self.accountSeenPushKind, gameID: gameID, address: address, - readAt: readAt + presenceUntil: presenceUntil ) } - private func shouldCoalesceAccountSeen(gameID: UUID, readAt: Date) -> Bool { - guard let previous = lastAccountSeenReadAt[gameID] else { return false } - return abs(readAt.timeIntervalSince(previous)) <= Self.accountSeenCoalesceWindow + private func shouldCoalesceAccountSeen(gameID: UUID, presenceUntil: Date) -> Bool { + guard let previous = lastAccountSeenPresenceUntil[gameID] else { return false } + return abs(presenceUntil.timeIntervalSince(previous)) <= Self.accountSeenCoalesceWindow } @discardableResult - private func publishAccountEvent(kind: String, gameID: UUID, readAt: Date? = nil) async -> Bool { + private func publishAccountEvent(kind: String, gameID: UUID, presenceUntil: Date? = nil) async -> Bool { guard let pushClient else { syncMonitor.note("push(\(kind)): skipped (no pushClient)") return false @@ -581,7 +581,7 @@ final class AccountPushCoordinator { kind: kind, gameID: gameID, address: address, - readAt: readAt + presenceUntil: presenceUntil ) return true } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -343,8 +343,8 @@ final class AppServices { store: store, syncMonitor: syncMonitor, readLeaseDuration: Self.readLeaseDuration, - publishAccountSeenPush: { [weak self] gameID, readAt in - await self?.accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt) + publishAccountSeenPush: { [weak self] gameID, presenceUntil in + await self?.accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil) } ) /// Friend-zone traffic — outbound invites, inbound ping handling, durable @@ -840,7 +840,7 @@ final class AppServices { } appDelegate.onRemoteNotification = { - summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in + summary, scope, event, gameID, kind, senderDeviceID, presenceUntil, isBackground in await self.handleRemoteNotification( summary: summary, scope: scope, @@ -848,7 +848,7 @@ final class AppServices { gameID: gameID, kind: kind, senderDeviceID: senderDeviceID, - readAt: readAt, + presenceUntil: presenceUntil, isBackground: isBackground ) } @@ -887,7 +887,7 @@ final class AppServices { if let currentID = store.currentEntity?.id, gameIDs.contains(currentID) { store.refreshCurrentGame() - // `readAt` doubles as the other-author read cursor: advancing it + // `presenceUntil` doubles as the other-author read cursor: advancing it // marks these incoming peer moves as seen. Gate on `isSuppressed` // — "the user is viewing *this* puzzle right now" — so moves are // marked read only while actually on screen, not merely because @@ -963,18 +963,18 @@ final class AppServices { // A sibling device of the same iCloud account has published its read // horizon; apply it directly because SyncEngine has already accepted // the Player record under last-writer-wins freshness checks. A - // future-dated readAt is an active-session lease — a sibling is in the + // future-dated presenceUntil is an active-session lease — a sibling is in the // puzzle right now — so withdraw any session notifications we already // delivered for that game (e.g. "X is solving"); opening it here is no - // longer something to nudge for. A past readAt is just a closed-session + // longer something to nudge for. A past presenceUntil is just a closed-session // horizon bump and leaves delivered notifications untouched. await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in let now = Date() - for (gameID, readAt, viewedAt) in pairs { - let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) + for (gameID, presenceUntil, viewedAt) in pairs { + let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil) self?.syncMonitor.note( - "cursor ADOPT[\(gameID.uuidString.prefix(8))] src=sync " + - "readAt=\(readAt.ISO8601Format()) " + + "lease ADOPT[\(gameID.uuidString.prefix(8))] src=sync " + + "presenceUntil=\(presenceUntil.ISO8601Format()) " + "was=\(previous?.ISO8601Format() ?? "—")" + (adopted ? "" : " (no-op)") ) @@ -985,18 +985,18 @@ final class AppServices { if let viewedAt { gameViewedStore.advance(viewedAt, forGame: gameID) } - if readAt > now { + if presenceUntil > now { await self?.badge.dismissDeliveredNotifications( for: gameID, - seenAt: readAt, + seenAt: presenceUntil, publishAccountSeen: false, preserveUnread: true ) } else if NotificationState.activePuzzleID() == gameID { self?.syncMonitor.note( - "cursor ADOPT[\(gameID.uuidString.prefix(8))] past while active; reasserting lease" + "lease ADOPT[\(gameID.uuidString.prefix(8))] past while active; reasserting" ) - // A past-dated readAt is a sibling closing its session, which + // A past-dated presenceUntil is a sibling closing its session, which // under last-writer-wins just pulled the shared account // horizon back to that close time. This device is still // actively viewing the same puzzle, so it still holds a @@ -1014,14 +1014,14 @@ final class AppServices { ) } else { // Sibling closed its session and nothing is on screen here: - // the account stopped looking at `readAt`. Pull the badge + // the account stopped looking at `presenceUntil`. Pull the badge // ledger's suppression horizon back to that instant (and // advance the watermark to it) so a push arriving after the // close badges here instead of staying swallowed under the // sibling's old lease, which this device adopted when the // lease was minted. - BadgeState.markSeen(gameID: gameID, at: readAt) - BadgeState.collapseSuppression(gameID: gameID, to: readAt) + BadgeState.markSeen(gameID: gameID, at: presenceUntil) + BadgeState.collapseSuppression(gameID: gameID, to: presenceUntil) } } } @@ -1765,7 +1765,7 @@ final class AppServices { gameID: UUID?, kind: String?, senderDeviceID: String?, - readAt: Date?, + presenceUntil: Date?, isBackground: Bool ) async { // Authoritative foreground correction. A content-available push is, by @@ -1794,7 +1794,7 @@ final class AppServices { kind: kind, gameID: gameID, senderDeviceID: senderDeviceID, - readAt: readAt + presenceUntil: presenceUntil ) { return } @@ -1879,7 +1879,7 @@ final class AppServices { kind: String?, gameID: UUID?, senderDeviceID: String?, - readAt: Date? + presenceUntil: Date? ) async -> Bool { guard let kind, kind == AccountPushCoordinator.accountJoinedPushKind || kind == AccountPushCoordinator.accountSeenPushKind @@ -1903,14 +1903,14 @@ final class AppServices { await accountPush.reconcilePushRegistration() await refreshSnapshot() case AccountPushCoordinator.accountSeenPushKind: - guard let readAt else { - syncMonitor.note("push(accountSeen): ignored (no readAt)") + guard let presenceUntil else { + syncMonitor.note("push(accountSeen): ignored (no presenceUntil)") return true } - let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) + let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil) syncMonitor.note( "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " + - "readAt=\(readAt.ISO8601Format()) " + + "presenceUntil=\(presenceUntil.ISO8601Format()) " + "was=\(previous?.ISO8601Format() ?? "—")" + (adopted ? "" : " (no-op)") ) @@ -1918,16 +1918,16 @@ final class AppServices { // accurate, on the sibling's `Player.viewedAt` via the // record sync this push's companion DB change triggers. This fast // push is now only the cross-device notification-dismissal signal. - // A forward-dated readAt is an active presence lease: the sibling is + // A forward-dated presenceUntil is an active presence lease: the sibling is // in this game right now and has seen its events live, so retract // even the unread-marking notifications (win/resign/pause) from this // device — the "soon-swept" half of sending to every device. A past - // readAt is a plain read watermark (the sibling left), where we still + // presenceUntil is a plain read watermark (the sibling left), where we still // preserve genuinely-unread alerts. - let siblingPresent = readAt > Date() + let siblingPresent = presenceUntil > Date() await badge.dismissDeliveredNotifications( for: gameID, - seenAt: readAt, + seenAt: presenceUntil, publishAccountSeen: false, preserveUnread: !siblingPresent ) @@ -2297,7 +2297,7 @@ final class AppServices { // collapse is always allowed: we must be able to *end* the lease on the // way to the background. if case .activeLease = mode, !isAppForeground { - syncMonitor.note("readCursor(activeLease) skipped for \(gameID.uuidString): backgrounded") + syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): backgrounded") return } // A re-assert triggered by an inbound sibling close (`requireActivePuzzle`) @@ -2310,18 +2310,18 @@ final class AppServices { if case .activeLease = mode, requireActivePuzzle, NotificationState.activePuzzleID() != gameID { - syncMonitor.note("readCursor(activeLease) skipped for \(gameID.uuidString): not active puzzle") + syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): not active puzzle") return } let now = Date() let didUpdate: Bool switch mode { case .activeLease: - let readAt = now.addingTimeInterval(Self.readLeaseDuration) + let presenceUntil = now.addingTimeInterval(Self.readLeaseDuration) didUpdate = store.setReadCursor( gameID: gameID, - readAt: readAt, - minimumExistingReadAt: now.addingTimeInterval(Self.readLeaseRefreshFloor) + presenceUntil: presenceUntil, + minimumExistingPresenceUntil: now.addingTimeInterval(Self.readLeaseRefreshFloor) ) if didUpdate { // Ghost-peer probe: every active-session lease passes through @@ -2331,15 +2331,15 @@ final class AppServices { // — and the foreground flag tells us which gate let it through. syncMonitor.note( "lease MINT[\(gameID.uuidString.prefix(8))] " + - "readAt=\(readAt.ISO8601Format()) foreground=\(isAppForeground) " + + "presenceUntil=\(presenceUntil.ISO8601Format()) foreground=\(isAppForeground) " + "suppressed=\(NotificationState.isSuppressed(gameID: gameID))" ) // Mirror the renewed lease into the badge ledger so an NSE // push landing mid-session stays suppressed past the open's // initial horizon (the open stamps one via // `dismissDeliveredNotifications`; this covers the refreshes). - BadgeState.adoptReadHorizon(gameID: gameID, horizon: readAt) - await accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt) + BadgeState.adoptReadHorizon(gameID: gameID, horizon: presenceUntil) + await accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil) } case .currentTime: // Leaving / backgrounding: collapse the presence lease to now and, @@ -2347,7 +2347,7 @@ final class AppServices { // looking right up to here, so they've seen everything through now. // The watermark write is what stops a peer re-summarising moves we // saw live just before leaving; it never reaches into the future. - let collapsed = store.setReadCursor(gameID: gameID, readAt: now) + let collapsed = store.setReadCursor(gameID: gameID, presenceUntil: now) let advanced = store.advanceReadThrough(gameID: gameID, through: now) // Collapse the badge ledger's suppression horizon in the same // lockstep. This write is local (App Group defaults), so it lands @@ -2363,13 +2363,13 @@ final class AppServices { let drain: Bool switch mode { case .activeLease: - reason = "readCursor(activeLease)" + reason = "lease(activeLease)" drain = true case .currentTime: // Exit/background cursor: enqueue durably but don't force a send. // Live presence rides the engagement socket; CloudKit carries the // cursor on its own schedule, off the scarce suspension budget. - reason = "readCursor(currentTime)" + reason = "lease(currentTime)" drain = false } await syncEngine.enqueuePlayer( @@ -2380,11 +2380,11 @@ final class AppServices { ) } - /// Diagnostic: logs each participant's `Player.readAt` lease for `gameID` + /// Diagnostic: logs each participant's `Player.presenceUntil` lease for `gameID` /// at open, so a lingering peer cursor or engagement room can be reasoned /// about from the device log alone — the lease value is otherwise only /// visible server-side. One line per player: self/peer, author prefix, - /// name, the raw `readAt` (UTC), and whether it currently reads as present + /// name, the raw `presenceUntil` (UTC), and whether it currently reads as present /// (`+Ns` until expiry) or lapsed (`Ns ago`). func logPlayerLeaseSnapshot(gameID: UUID) async { let localAuthorID = identity.currentID @@ -2401,12 +2401,12 @@ final class AppServices { || (localAuthorID.map { author == $0 } ?? false) let tag = isLocal ? "self" : "peer" let name = (player.name?.isEmpty == false) ? player.name! : "—" - guard let readAt = player.readAt else { - return "\(tag) \(author.prefix(8)) [\(name)] readAt=nil" + guard let presenceUntil = player.presenceUntil else { + return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=nil" } - let delta = Int(readAt.timeIntervalSince(now)) + let delta = Int(presenceUntil.timeIntervalSince(now)) let state = delta > 0 ? "present, +\(delta)s" : "absent, \(-delta)s ago" - return "\(tag) \(author.prefix(8)) [\(name)] readAt=\(readAt.ISO8601Format()) (\(state))" + return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=\(presenceUntil.ISO8601Format()) (\(state))" } continuation.resume(returning: lines) } @@ -2434,7 +2434,7 @@ final class AppServices { } /// True iff some non-local participant in `gameID` currently holds a - /// valid read lease (`readAt` in the future). The active-lease cursor — + /// valid read lease (`presenceUntil` in the future). The active-lease cursor — /// set ~10 minutes ahead while the puzzle is open and collapsed to `now` /// on leave — is the presence signal: it survives think-time without /// cursor movement and self-expires if a peer vanishes uncleanly, so a @@ -2451,7 +2451,7 @@ final class AppServices { let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate( - format: "game.id == %@ AND readAt > %@", + format: "game.id == %@ AND presenceUntil > %@", gameID as CVarArg, PeerPresence.presenceCutoff(asOf: now) as NSDate ) @@ -2460,14 +2460,14 @@ final class AppServices { guard let authorID = player.authorID, !authorID.isEmpty else { return false } if authorID == CKCurrentUserDefaultName { return false } if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { return false } - return PeerPresence.isPresent(readAt: player.readAt, asOf: now) + return PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now) } continuation.resume(returning: hasPeer) } } } - /// The earliest future `readAt` among non-local participants in `gameID` — + /// The earliest future `presenceUntil` among non-local participants in `gameID` — /// i.e. when the soonest peer lease lapses — or `nil` if no peer is /// present. `nil` is the same condition `hasPresentPeer` reports as absent, /// so callers can derive presence from this and also schedule against the @@ -2483,7 +2483,7 @@ final class AppServices { let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate( - format: "game.id == %@ AND readAt > %@", + format: "game.id == %@ AND presenceUntil > %@", gameID as CVarArg, PeerPresence.presenceCutoff(asOf: now) as NSDate ) @@ -2492,9 +2492,9 @@ final class AppServices { guard let authorID = player.authorID, !authorID.isEmpty else { continue } if authorID == CKCurrentUserDefaultName { continue } if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { continue } - guard let readAt = player.readAt, - PeerPresence.isPresent(readAt: readAt, asOf: now) else { continue } - if soonest == nil || readAt < soonest! { soonest = readAt } + guard let presenceUntil = player.presenceUntil, + PeerPresence.isPresent(presenceUntil: presenceUntil, asOf: now) else { continue } + if soonest == nil || presenceUntil < soonest! { soonest = presenceUntil } } continuation.resume(returning: soonest) } @@ -2512,7 +2512,7 @@ final class AppServices { let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") var predicates = [ - NSPredicate(format: "readAt > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate) + NSPredicate(format: "presenceUntil > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate) ] if let gameIDs, !gameIDs.isEmpty { predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs))) @@ -2528,7 +2528,7 @@ final class AppServices { let authorID = player.authorID, !authorID.isEmpty, authorID != CKCurrentUserDefaultName, - PeerPresence.isPresent(readAt: player.readAt, asOf: now) else { continue } + PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now) else { continue } result[gameID, default: []].insert(authorID) } continuation.resume(returning: result.mapValues { Array($0) }) diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift @@ -14,7 +14,7 @@ final class BadgeCoordinator { /// forward the suppression horizon is stamped while the puzzle is open. private let readLeaseDuration: TimeInterval /// Fans the seen horizon out to sibling devices — - /// `AccountPushCoordinator.publishAccountSeenPush(gameID:readAt:)`. + /// `AccountPushCoordinator.publishAccountSeenPush(gameID:presenceUntil:)`. private let publishAccountSeenPush: (UUID, Date) async -> Void private var lastLoggedBadgeSnapshot: BadgeRefreshSnapshot? @@ -44,7 +44,7 @@ final class BadgeCoordinator { /// this device's Notification Center. Sibling devices of the same iCloud /// account learn about the dismissal indirectly: a directed ping is /// deleted on consumption (the `onPingDeleted` path then withdraws their - /// copy), and the unread-moves badge converges via `Player.readAt`. + /// copy), and the unread-moves badge converges via `Player.presenceUntil`. /// /// Every dismissal path is also a "user has seen this game" signal, so /// we advance the App Group badge ledger's seen horizon and refresh the diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift @@ -13,7 +13,7 @@ final class EngagementLifecycle { /// How often a foregrounded shared puzzle re-runs the engagement reconnect /// check. Engagement auto-connect is otherwise edge-triggered (channel /// close, foreground, a peer's cursor move), so a drop whose re-connect - /// edge never lands — a failed connect, a `readAt`-only lease refresh, an + /// edge never lands — a failed connect, a `presenceUntil`-only lease refresh, an /// edge lost to suspension — stays disconnected until the user nudges it /// manually. This timer is the backstop. It re-runs `peerPresenceMayHave\ /// Changed`, which is a no-op unless the coordinator is idle *and* a peer @@ -216,7 +216,7 @@ final class EngagementLifecycle { /// /// 1. Renews our own read lease (`publishReadCursor(.activeLease)`, which /// is floor-gated so it writes at most ~once per 5 min). This is what - /// makes `readAt` a true "foregrounded on this puzzle" heartbeat: a + /// makes `presenceUntil` a true "foregrounded on this puzzle" heartbeat: a /// peer's own typing never advances their own lease, so without this an /// active solo driver would lapse mid-session and look absent. /// 2. Re-runs the coordinator's presence check, which both reconnects a diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -405,11 +405,11 @@ final class PushClient { kind: String, gameID: UUID, address: String, - readAt: Date? = nil + presenceUntil: Date? = nil ) async { var extra: [String: Any] = [:] - if let readAt { - extra["readAt"] = Self.iso8601.string(from: readAt) + if let presenceUntil { + extra["presenceUntil"] = Self.iso8601.string(from: presenceUntil) } await publish( kind: kind, diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -361,7 +361,7 @@ final class SessionCoordinator { let journalEntries = store.localJournalEntries(for: gameID) // Sender-side diagnostics: store-derived measurements plus this // device's clock and the session-start it announced. Rides the - // per-recipient payload (the planner stamps each recipient's readAt) + // per-recipient payload (the planner stamps each recipient's presenceUntil) // so the receiver can log why the counts came out as they did. var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID) ?? PushPayload.Diagnostics() @@ -407,7 +407,7 @@ final class SessionCoordinator { ) // Advance each addressed recipient's notified-through watermark to the // latest move this pause reported. A later pause windows its counts to - // the later of this and the recipient's readAt, so a bounce that adds + // the later of this and the recipient's presenceUntil, so a bounce that adds // no new move re-tallies to zero and reaches no one instead of // repeating the same summary. Only recipients we actually pushed to // advance: a recipient dropped for no letter changes (or no push @@ -725,16 +725,16 @@ final class SessionCoordinator { /// pushed count can be diffed field-for-field against local ground truth. /// Phantom cells that actually synced surface here too; ones that stayed /// local to the sender (un-uploaded churn) won't — which is itself the - /// answer. `recipientReadAt` carries this device's *actual* cursor, to + /// answer. `recipientPresenceUntil` carries this device's *actual* cursor, to /// compare against the value the peer's pushed diagnostics claimed it saw. private func logLocalPauseDiagnostics(for gameID: UUID) { let localAuthorID = identity.currentID - let selfReadAt = localAuthorID.flatMap { store.readAt(for: gameID, by: $0) } + let selfPresenceUntil = localAuthorID.flatMap { store.presenceUntil(for: gameID, by: $0) } for peerAuthorID in store.peerAuthorIDs(for: gameID, excluding: localAuthorID) { guard var diagnostics = store.movesDiagnostics(for: gameID, by: peerAuthorID) else { continue } diagnostics.senderNow = Date() - diagnostics.recipientReadAt = selfReadAt + diagnostics.recipientPresenceUntil = selfPresenceUntil syncMonitor.note( "local pause diag peer=\(peerAuthorID.prefix(8)): \(diagnostics.summaryLine)" ) diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -21,7 +21,7 @@ struct PushRecipient: Sendable, Equatable { let readThrough: Date? /// Sender-local watermark: the latest authored move we've already told /// this recipient about via a previous pause. The session-end tally - /// windows on the later of this and `readAt`, so we never re-report a + /// windows on the later of this and `presenceUntil`, so we never re-report a /// move the recipient has already seen *or* already been notified of. /// `nil` when we've never paused to them. Never synced — see /// `PlayerEntity.notifiedThrough`. @@ -104,7 +104,7 @@ enum SessionPushPlanner { // diagnostic, since it means the count covered the author's // entire history. var perRecipient = diagnostics - perRecipient?.recipientReadAt = cutoff + perRecipient?.recipientPresenceUntil = cutoff return PushClient.Addressee( address: address, body: body, diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -2,37 +2,37 @@ import CloudKit import Foundation /// The single rule for "is a peer present." A peer is present iff their -/// active-session lease (`Player.readAt`) is still in the future, or lapsed no +/// active-session lease (`Player.presenceUntil`) is still in the future, or lapsed no /// more than `presenceGrace` ago — the cursor (`PlayerRoster`), the engagement /// icon, engagement teardown, and the selection-publisher send gate all derive /// presence from this. /// -/// The grace exists because `readAt` doubles as the account read horizon and is +/// The grace exists because `presenceUntil` doubles as the account read horizon and is /// legitimately collapsed to a current-time value whenever a device /// backgrounds or leaves (it must close the lease synchronously, without a /// background assertion it can't rely on). A co-solver bouncing between apps — -/// expected to be common — therefore writes a current-time `readAt` and returns +/// expected to be common — therefore writes a current-time `presenceUntil` and returns /// seconds later. Without a grace the partner would see them blink out and back /// on every such hop; the grace treats a brief absence as continued presence, /// at the cost of a departed peer lingering for up to `presenceGrace`. enum PeerPresence { - /// How long a lapsed `readAt` still counts as present. Sized to cover a + /// How long a lapsed `presenceUntil` still counts as present. Sized to cover a /// realistic app-switch — glancing at and replying to a notification — with /// margin, while clearing a genuine departure within about a minute. The /// costs are asymmetric: too short reintroduces the blink, too long only /// lingers a stale cursor, so this rounds up. static let presenceGrace: TimeInterval = 60 - /// The cutoff a `readAt` must exceed to count as present. Exposed for + /// The cutoff a `presenceUntil` must exceed to count as present. Exposed for /// callers that filter in a query rather than per-record (Core Data /// predicates), so they stay in lockstep with `isPresent`. static func presenceCutoff(asOf now: Date = Date()) -> Date { now.addingTimeInterval(-presenceGrace) } - static func isPresent(readAt: Date?, asOf now: Date = Date()) -> Bool { - guard let readAt else { return false } - return readAt > presenceCutoff(asOf: now) + static func isPresent(presenceUntil: Date?, asOf now: Date = Date()) -> Bool { + guard let presenceUntil else { return false } + return presenceUntil > presenceCutoff(asOf: now) } } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -5,7 +5,7 @@ import Foundation struct BatchEffects { var movesUpdated = Set<UUID>() /// Games whose roster-relevant state changed in this batch — a Player - /// record (name / selection / readAt), a Game record (share metadata), a + /// record (name / selection / presenceUntil), a Game record (share metadata), a /// deletion (participant removal), or a *new* contributor's first Moves /// row (a participant the roster can only discover from their moves; see /// `applyMovesRecord`'s `onNewAuthor`). Drives `.playerRosterShouldRefresh`. @@ -294,10 +294,10 @@ extension SyncEngine { // inbound peer moves read on arrival regardless. Keeping "A left // while C is still here" representable without the dip would need // per-device Player rows, which don't exist (one row per author). - let previousReadAt = entity.readAt + let previousPresenceUntil = entity.presenceUntil let previousViewedAt = entity.viewedAt - let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record) - entity.readAt = incomingReadAt + let incomingPresenceUntil = RecordSerializer.parsePlayerPresenceUntil(from: record) + entity.presenceUntil = incomingPresenceUntil let incomingReadThrough = RecordSerializer.parsePlayerReadThrough(from: record) entity.readThrough = incomingReadThrough entity.viewedAt = RecordSerializer.parsePlayerViewedAt(from: record) @@ -331,9 +331,9 @@ extension SyncEngine { // guard above admits it — carries no new account-horizon information, // and adopting it would rewind the just-minted lease and trigger a // redundant re-assert save (the open-time double mint). - if authorID == localAuthorID, let readAt = incomingReadAt, - readAt != previousReadAt || entity.viewedAt != previousViewedAt { - onReadCursor(gameID, readAt, entity.viewedAt) + if authorID == localAuthorID, let presenceUntil = incomingPresenceUntil, + presenceUntil != previousPresenceUntil || entity.viewedAt != previousViewedAt { + onReadCursor(gameID, presenceUntil, entity.viewedAt) } // Our own record coming back from another of this account's devices // carries that device's read watermark. Adopt it monotonically onto the diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -133,7 +133,7 @@ extension SyncEngine { name: renderedName, updatedAt: updatedAt, selection: selection, - readAt: entity.game?.lastReadOtherMoveAt, + presenceUntil: entity.game?.lastReadOtherMoveAt, readThrough: entity.game?.readThroughAt, viewedAt: entity.viewedAt, timeLog: entity.timeLog, diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -519,7 +519,7 @@ enum RecordSerializer { name: String, updatedAt: Date, selection: PlayerSelection?, - readAt: Date? = nil, + presenceUntil: Date? = nil, readThrough: Date? = nil, viewedAt: Date? = nil, timeLog: Data? = nil, @@ -547,12 +547,10 @@ enum RecordSerializer { record["selCol"] = nil record["selDir"] = nil } - // The forward-dated presence lease. Shipped as the `presenceUntil` - // CKRecord field (renamed from `readAt` in the v4 schema); the local - // `readAt` parameter and Core Data attributes still carry the old name - // — an internal rename deliberately left for later. - if let readAt { - record["presenceUntil"] = readAt as CKRecordValue + // The forward-dated presence lease, shipped as the `presenceUntil` + // CKRecord field. + if let presenceUntil { + record["presenceUntil"] = presenceUntil as CKRecordValue } else { record["presenceUntil"] = nil } @@ -587,7 +585,7 @@ enum RecordSerializer { return record } - /// Reads `readAt` off an inbound Player record — the per-account horizon + /// Reads `presenceUntil` off an inbound Player record — the per-account horizon /// for collaborator moves this user has read or is actively watching. /// Active puzzle sessions may lease the horizon into the near future and /// close it with a lower current-time write, so callers must apply this @@ -595,13 +593,13 @@ enum RecordSerializer { /// freshness checks. /// Returns `nil` if the field is missing — older records, or a slot that /// has not yet recorded a view. - static func parsePlayerReadAt(from record: CKRecord) -> Date? { + static func parsePlayerPresenceUntil(from record: CKRecord) -> Date? { record["presenceUntil"] as? Date } /// Reads `readThrough` off an inbound Player record — the per-account read /// watermark: the latest other-author move time this user has actually - /// seen. Unlike `readAt` it is never leased into the future, so the + /// seen. Unlike `presenceUntil` it is never leased into the future, so the /// session-end push uses it to decide what a recipient still hasn't seen. /// Returns `nil` for records that predate the field or a slot that has not /// recorded a read yet. diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -186,7 +186,7 @@ actor SyncEngine { /// device consumed a directed ping). Drives cross-device withdrawal of the /// notification and any local durable invite row this device may hold. var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)? - /// Fires with (gameID, readAt) pairs lifted from inbound Player records + /// Fires with (gameID, presenceUntil) pairs lifted from inbound Player records /// whose authorID matches the local user. A sibling device has recorded /// the account's read horizon; active sessions may move it into the near /// future and later close it with a lower current-time value. @@ -701,7 +701,7 @@ actor SyncEngine { /// 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.readAt`), so any + /// 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 @@ -2305,7 +2305,7 @@ extension SyncEngine: CKSyncEngineDelegate { /// someone else's presence, which `RecordBuilder` stamps with our own /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that /// horizon is a live read lease, the peer is resurrected as present with - /// our future `readAt`. We log the value the build will send so a future + /// our future `presenceUntil`. We log the value the build will send so a future /// lease (the ghost) is distinguishable from a current-time close, and so /// the next recurrence names the culprit in one line rather than leaving it /// to inference. Silent in the normal case (own slot only), so it adds no @@ -2326,19 +2326,19 @@ extension SyncEngine: CKSyncEngineDelegate { guard !foreign.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() for (gameID, authorID) in foreign { - let readAt: Date? = await ctx.perform { + let presenceUntil: Date? = await ctx.perform { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt } let leaseDesc: String - if let readAt { - let delta = Int(readAt.timeIntervalSinceNow) - leaseDesc = "readAt=\(readAt.ISO8601Format()) " + + if let presenceUntil { + let delta = Int(presenceUntil.timeIntervalSinceNow) + leaseDesc = "presenceUntil=\(presenceUntil.ISO8601Format()) " + (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)") } else { - leaseDesc = "readAt=nil" + leaseDesc = "presenceUntil=nil" } await trace( "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " + diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -95,7 +95,7 @@ enum NotificationState { /// The unified suppression gate: the user is viewing `gameID` here /// (including the local leave-grace tail). Sibling-device presence used /// to factor in here via the `.opened`/`.closed` lease; that subsystem is - /// gone — cross-device read state now rides `Player.readAt`. The + /// gone — cross-device read state now rides `Player.presenceUntil`. The /// gate is kept under this name so callers don't need to change. static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool { isActive(gameID: gameID, now: now) @@ -212,7 +212,7 @@ enum NotificationState { /// Folding both meanings into a forward-dated `seenAt`, as before, made the /// suppression permanent: `markSeen` is monotonic, so the badge swallowed /// every push for the rest of the lease window even after the user left — -/// the push-ledger twin of the `readAt`/`readThrough` conflation PLAN.md +/// the push-ledger twin of the `presenceUntil`/`readThrough` conflation PLAN.md /// describes. enum BadgeState { private static let ledgerKey = "badge.ledger.v2" @@ -292,7 +292,7 @@ enum BadgeState { /// Records an account read horizon that may be forward-dated (a presence /// lease, locally minted or received from a sibling device): the watermark /// advances only to `min(horizon, now)`, while the suppression horizon - /// takes the full value. The two halves mirror the `readThrough`/`readAt` + /// takes the full value. The two halves mirror the `readThrough`/`presenceUntil` /// split on the Player record. static func adoptReadHorizon(gameID: UUID, horizon: Date, now: Date = Date()) { markSeen(gameID: gameID, at: min(horizon, now)) @@ -382,7 +382,7 @@ 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, and repair stale first-pass + /// 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 } diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -69,10 +69,10 @@ struct PushPayload: Codable, Sendable, Equatable { /// populates it — it is always `nil` in practice and kept only so the /// receipt log keeps a stable slot should a session-start signal return. var sessionStart: Date? = nil - /// The recipient's `Player.readAt` *as the sender saw it* — the exact + /// The recipient's `Player.presenceUntil` *as the sender saw it* — the exact /// cutoff the per-recipient diff used. A stale value here widens the /// counting window. - var recipientReadAt: Date? = nil + var recipientPresenceUntil: Date? = nil /// The grid geometry the sender currently holds for the puzzle. var gridWidth: Int? = nil var gridHeight: Int? = nil @@ -112,7 +112,7 @@ struct PushPayload: Codable, Sendable, Equatable { func int(_ value: Int?) -> String { value.map(String.init) ?? "—" } return "now=\(iso(senderNow))" + " sessionStart=\(iso(sessionStart))" - + " recipientReadAt=\(iso(recipientReadAt))" + + " recipientPresenceUntil=\(iso(recipientPresenceUntil))" + " grid=\(int(gridWidth))x\(int(gridHeight))" + " cm=\(int(cmVersion))" + " merged=\(int(mergedCells))" diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -51,7 +51,7 @@ enum PuzzleNotificationText { /// Body for a session-end push, addressed to a single recipient, /// describing what the peer did since that recipient last looked (entries /// in the peer's journal newer than the recipient's last-known - /// `Player.readAt`): net letter `fills` / `clears`, and the number of + /// `Player.presenceUntil`): net letter `fills` / `clears`, and the number of /// `checks` / `reveals` *gestures* run. When every count is zero the /// recipient still gets the push as a presence signal ("stopped solving") /// — the session end is worth surfacing even with nothing unseen — but the diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -303,7 +303,7 @@ struct GameStoreUnreadMovesTests { #expect(!summary.hasUnreadOtherMoves) } - @Test("A sibling's readAt presence lease is adopted last-writer-wins") + @Test("A sibling's presenceUntil presence lease is adopted last-writer-wins") func incomingReadCursorSetsBadgeHorizon() throws { let persistence = makeTestPersistence() let store = makeTestStore( @@ -318,24 +318,24 @@ struct GameStoreUnreadMovesTests { let future = Date(timeIntervalSinceNow: 10 * 60) // All of an account's devices share one Player record, so the inbound - // `readAt` is the account's resolved *presence lease*: adopt it verbatim + // `presenceUntil` is the account's resolved *presence lease*: adopt it verbatim // under last-writer-wins. This is the presence horizon only; the unread // badge is driven by the read watermark (see the watermark test below). - store.noteIncomingReadCursor(gameID: gameID, readAt: earlier) + store.noteIncomingReadCursor(gameID: gameID, presenceUntil: earlier) #expect(entity.lastReadOtherMoveAt == earlier) - store.noteIncomingReadCursor(gameID: gameID, readAt: later) + store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later) #expect(entity.lastReadOtherMoveAt == later) // A sibling opens an active session and leases the horizon into the // future; the lease is adopted verbatim. - store.noteIncomingReadCursor(gameID: gameID, readAt: future) + store.noteIncomingReadCursor(gameID: gameID, presenceUntil: future) #expect(entity.lastReadOtherMoveAt == future) // That sibling leaves and publishes the current time. Under // last-writer-wins the single shared scalar simply moves back to the // close value — there is no per-device lease here to protect it. - store.noteIncomingReadCursor(gameID: gameID, readAt: later) + store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later) #expect(entity.lastReadOtherMoveAt == later) } @@ -366,7 +366,7 @@ struct GameStoreUnreadMovesTests { // A future presence lease must NOT clear the badge — this is the bug: // a leased-but-backgrounded reader had moves silently swallowed. - #expect(store.setReadCursor(gameID: gameID, readAt: future)) + #expect(store.setReadCursor(gameID: gameID, presenceUntil: future)) #expect(entity.lastReadOtherMoveAt == future) #expect(store.unreadOtherMovesGameCount() == 1) #expect(store.hasUnreadOtherMoves(gameID: gameID)) @@ -508,20 +508,20 @@ struct GameStoreUnreadMovesTests { let floor = now.addingTimeInterval(5 * 60) let refreshed = now.addingTimeInterval(10 * 60) - #expect(store.setReadCursor(gameID: gameID, readAt: farEnough)) + #expect(store.setReadCursor(gameID: gameID, presenceUntil: farEnough)) #expect(!store.setReadCursor( gameID: gameID, - readAt: refreshed, - minimumExistingReadAt: floor + presenceUntil: refreshed, + minimumExistingPresenceUntil: floor )) #expect(entity.lastReadOtherMoveAt == farEnough) let tooClose = now.addingTimeInterval(4 * 60) - #expect(store.setReadCursor(gameID: gameID, readAt: tooClose)) + #expect(store.setReadCursor(gameID: gameID, presenceUntil: tooClose)) #expect(store.setReadCursor( gameID: gameID, - readAt: refreshed, - minimumExistingReadAt: floor + presenceUntil: refreshed, + minimumExistingPresenceUntil: floor )) #expect(entity.lastReadOtherMoveAt == refreshed) } diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -63,7 +63,7 @@ struct PlayerRosterTests { persistence: PersistenceController, selection: PlayerSelection? = nil, updatedAt: Date = Date(), - readAt: Date? = Date().addingTimeInterval(600), + presenceUntil: Date? = Date().addingTimeInterval(600), timeLog: TimeLog? = nil ) { let ctx = persistence.viewContext @@ -76,7 +76,7 @@ struct PlayerRosterTests { player.name = name player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) player.updatedAt = updatedAt - player.readAt = readAt + player.presenceUntil = presenceUntil if let timeLog { player.timeLog = TimeLog.encode(timeLog) } @@ -282,7 +282,7 @@ struct PlayerRosterTests { selection: PlayerSelection(row: 1, col: 2, direction: .across), updatedAt: Date(), // Lapsed well past the presence grace so the peer reads as gone. - readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)) + presenceUntil: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)) ) let roster = makeRoster(gameID: gameID, persistence: persistence) @@ -302,7 +302,7 @@ struct PlayerRosterTests { persistence: persistence, selection: PlayerSelection(row: 1, col: 2, direction: .across), updatedAt: Date().addingTimeInterval(-300), - readAt: Date().addingTimeInterval(600) + presenceUntil: Date().addingTimeInterval(600) ) let roster = makeRoster(gameID: gameID, persistence: persistence) diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -108,7 +108,7 @@ struct PushPayloadTests { let diagnostics = PushPayload.Diagnostics( senderNow: Date(timeIntervalSince1970: 2_000), sessionStart: Date(timeIntervalSince1970: 1_500), - recipientReadAt: Date(timeIntervalSince1970: 1_000), + recipientPresenceUntil: Date(timeIntervalSince1970: 1_000), gridWidth: 15, gridHeight: 15, cmVersion: 3, @@ -132,7 +132,7 @@ struct PushPayloadTests { #expect(decoded == payload) #expect(decoded.diagnostics?.mergedCells == 200) - #expect(decoded.diagnostics?.recipientReadAt == Date(timeIntervalSince1970: 1_000)) + #expect(decoded.diagnostics?.recipientPresenceUntil == Date(timeIntervalSince1970: 1_000)) } @Test("A pause without diagnostics decodes with nil diagnostics") @@ -169,7 +169,7 @@ struct PushPayloadTests { #expect(line.contains("grid=15x15")) #expect(line.contains("merged=200")) - #expect(line.contains("recipientReadAt=—")) + #expect(line.contains("recipientPresenceUntil=—")) } @Test("Only unseen content marks a game unread") diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -116,27 +116,27 @@ struct RecordSerializerTests { ]) } - @Test("playerRecord writes readAt and parses it back") - func playerRecordReadAtRoundTrip() { + @Test("playerRecord writes presenceUntil and parses it back") + func playerRecordPresenceUntilRoundTrip() { let id = UUID() let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) - let readAt = Date(timeIntervalSince1970: 1_700_000_000) + let presenceUntil = Date(timeIntervalSince1970: 1_700_000_000) let record = RecordSerializer.playerRecord( gameID: id, authorID: "alice", name: "Alice", updatedAt: Date(timeIntervalSince1970: 1_700_000_100), selection: nil, - readAt: readAt, + presenceUntil: presenceUntil, zone: zone, systemFields: nil ) - #expect(record["presenceUntil"] as? Date == readAt) - #expect(RecordSerializer.parsePlayerReadAt(from: record) == readAt) + #expect(record["presenceUntil"] as? Date == presenceUntil) + #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == presenceUntil) } - @Test("playerRecord omits readAt when nil and parser returns nil") - func playerRecordReadAtNil() { + @Test("playerRecord omits presenceUntil when nil and parser returns nil") + func playerRecordPresenceUntilNil() { let id = UUID() let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) let record = RecordSerializer.playerRecord( @@ -149,7 +149,7 @@ struct RecordSerializerTests { systemFields: nil ) #expect(record["presenceUntil"] == nil) - #expect(RecordSerializer.parsePlayerReadAt(from: record) == nil) + #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == nil) } @Test("playerRecord writes pushAddress and parses it back") diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -102,11 +102,11 @@ struct SessionPushPlannerTests { // recipient is dropped entirely rather than getting a duplicate summary. let edit = Date(timeIntervalSince1970: 1_000) let entries = [entry("X", at: edit, seq: 1, col: 0)] - let staleReadAt = edit.addingTimeInterval(-3_600) + let stalePresenceUntil = edit.addingTimeInterval(-3_600) // First pause (never notified): the fill is news to the recipient. let firstPause = SessionPushPlanner.sessionEndAddressees( - recipients: [recipient("addr", readThrough: staleReadAt)], + recipients: [recipient("addr", readThrough: stalePresenceUntil)], journalEntries: entries, selfAuthorID: "author", playerName: "Alice", @@ -118,7 +118,7 @@ struct SessionPushPlannerTests { // Second pause after a bounce with no new move: readThrough is still stale, // but the watermark already covers `edit`, so nothing is re-reported. let secondPause = SessionPushPlanner.sessionEndAddressees( - recipients: [recipient("addr", readThrough: staleReadAt, notifiedThrough: edit)], + recipients: [recipient("addr", readThrough: stalePresenceUntil, notifiedThrough: edit)], journalEntries: entries, selfAuthorID: "author", playerName: "Alice", @@ -314,8 +314,8 @@ struct SessionPushPlannerTests { func diagnosticsStampedPerRecipient() { let edit = Date(timeIntervalSince1970: 1_000) let entries = [entry("X", at: edit, seq: 1, col: 0)] - let aReadAt = edit.addingTimeInterval(-60) - let recipientA = recipient("addr-a", readThrough: aReadAt) + let aPresenceUntil = edit.addingTimeInterval(-60) + let recipientA = recipient("addr-a", readThrough: aPresenceUntil) let recipientB = recipient("addr-b", readThrough: nil) let base = PushPayload.Diagnostics( gridWidth: 15, @@ -334,13 +334,13 @@ struct SessionPushPlannerTests { let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) }) let diagnosticsA = byAddress["addr-a"]?.payload?.diagnostics - #expect(diagnosticsA?.recipientReadAt == aReadAt) + #expect(diagnosticsA?.recipientPresenceUntil == aPresenceUntil) #expect(diagnosticsA?.gridWidth == 15) #expect(diagnosticsA?.mergedCells == 200) - // A recipient with no cursor keeps recipientReadAt nil — itself + // A recipient with no cursor keeps recipientPresenceUntil nil — itself // diagnostic, since the count then covered the whole history. let diagnosticsB = byAddress["addr-b"]?.payload?.diagnostics - #expect(diagnosticsB?.recipientReadAt == nil) + #expect(diagnosticsB?.recipientPresenceUntil == nil) #expect(diagnosticsB?.gridWidth == 15) } diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift @@ -66,7 +66,7 @@ struct AppServicesPeerPresenceTests { gameID: gameID, authorID: "bob", selection: nil, - readAt: Date().addingTimeInterval(10 * 60), + presenceUntil: Date().addingTimeInterval(10 * 60), updatedAt: Date(), persistence: persistence ) @@ -94,7 +94,7 @@ struct AppServicesPeerPresenceTests { authorID: "bob", selection: PlayerSelection(row: 1, col: 2, direction: .down), // Lapsed past the presence grace so it no longer counts as present. - readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)), + presenceUntil: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)), updatedAt: Date(), persistence: persistence ) @@ -123,7 +123,7 @@ struct AppServicesPeerPresenceTests { selection: PlayerSelection(row: 1, col: 2, direction: .down), // A peer who bounced to another app seconds ago: lease collapsed to // a recent current-time value, still inside the grace. - readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace - 10)), + presenceUntil: Date().addingTimeInterval(-(PeerPresence.presenceGrace - 10)), updatedAt: Date(), persistence: persistence ) @@ -150,7 +150,7 @@ struct AppServicesPeerPresenceTests { gameID: gameID, authorID: "bob", selection: PlayerSelection(row: 1, col: 2, direction: .down), - readAt: nil, + presenceUntil: nil, updatedAt: Date(), persistence: persistence ) @@ -185,7 +185,7 @@ struct AppServicesPeerPresenceTests { gameID: gameID, authorID: authorID, selection: PlayerSelection(row: 0, col: 0, direction: .across), - readAt: nil, + presenceUntil: nil, updatedAt: Date(), persistence: persistence ) @@ -196,7 +196,7 @@ struct AppServicesPeerPresenceTests { gameID: UUID, authorID: String, selection: PlayerSelection?, - readAt: Date?, + presenceUntil: Date?, updatedAt: Date = Date(), persistence: PersistenceController ) throws { @@ -211,7 +211,7 @@ struct AppServicesPeerPresenceTests { player.name = authorID player.updatedAt = updatedAt player.ckRecordName = "player-\(gameID.uuidString)-\(authorID)" - player.readAt = readAt + player.presenceUntil = presenceUntil if let selection { player.selRow = NSNumber(value: selection.row) player.selCol = NSNumber(value: selection.col) diff --git a/Tests/Unit/Sync/PeerPresenceGraceTests.swift b/Tests/Unit/Sync/PeerPresenceGraceTests.swift @@ -9,30 +9,30 @@ struct PeerPresenceGraceTests { @Test("a live (future) lease is present") func futureLeaseIsPresent() { - #expect(PeerPresence.isPresent(readAt: now.addingTimeInterval(600), asOf: now)) + #expect(PeerPresence.isPresent(presenceUntil: now.addingTimeInterval(600), asOf: now)) } @Test("a lease lapsed within the grace still counts as present") func recentlyLapsedLeaseIsPresent() { let lapsed = now.addingTimeInterval(-(PeerPresence.presenceGrace - 5)) - #expect(PeerPresence.isPresent(readAt: lapsed, asOf: now)) + #expect(PeerPresence.isPresent(presenceUntil: lapsed, asOf: now)) } @Test("a lease lapsed past the grace is absent") func longLapsedLeaseIsAbsent() { let lapsed = now.addingTimeInterval(-(PeerPresence.presenceGrace + 5)) - #expect(!PeerPresence.isPresent(readAt: lapsed, asOf: now)) + #expect(!PeerPresence.isPresent(presenceUntil: lapsed, asOf: now)) } @Test("no lease is absent") func noLeaseIsAbsent() { - #expect(!PeerPresence.isPresent(readAt: nil, asOf: now)) + #expect(!PeerPresence.isPresent(presenceUntil: nil, asOf: now)) } @Test("the cutoff is the grace before now and agrees with isPresent") func cutoffMatchesGrace() { #expect(PeerPresence.presenceCutoff(asOf: now) == now.addingTimeInterval(-PeerPresence.presenceGrace)) - // A readAt exactly at the cutoff is not present (strictly greater wins). - #expect(!PeerPresence.isPresent(readAt: PeerPresence.presenceCutoff(asOf: now), asOf: now)) + // A presenceUntil exactly at the cutoff is not present (strictly greater wins). + #expect(!PeerPresence.isPresent(presenceUntil: PeerPresence.presenceCutoff(asOf: now), asOf: now)) } } diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift @@ -192,14 +192,14 @@ struct PlayerRecordPresenceTests { // snapshot + read cursor but with a stale `updatedAt`, because leaving // advances the read cursor and snapshot without bumping `updatedAt`. let baseline = Date(timeIntervalSince1970: 12) - let readAt = Date(timeIntervalSince1970: 15) + let presenceUntil = Date(timeIntervalSince1970: 15) let record = RecordSerializer.playerRecord( gameID: gameID, authorID: localAuthorID, name: "Local", updatedAt: Date(timeIntervalSince1970: 10), selection: PlayerSelection(row: 5, col: 5, direction: .across), - readAt: readAt, + presenceUntil: presenceUntil, viewedAt: baseline, zone: zoneID, systemFields: nil @@ -221,7 +221,7 @@ struct PlayerRecordPresenceTests { #expect(row.viewedAt == baseline) #expect(readCursors.count == 1) #expect(readCursors.first?.0 == gameID) - #expect(readCursors.first?.1 == readAt) + #expect(readCursors.first?.1 == presenceUntil) #expect(readCursors.first?.2 == baseline) // ...while the cursor LWW still shields the fresher local selection from // the stale record.