crossmate

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

commit 41f17411431fd219e259038be58c12c233911788
parent 589feeaea00a62dc8f2ffde711506883bfa1dc41
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 03:27:37 +0900

Stop cursor moves bumping a game's Game List timestamp

Opening a shared game and moving the cursor — without playing a single
letter — floated that game to the top of the Game List and refreshed its
'updated …' label, as though something had happened on the board.

The cause was that applyGameRecord seeded GameEntity.updatedAt from the
Game record's system modificationDate on every fetch. That timestamp
advances on any write to the record, gameplay or not — minting the
engagement or push credentials, share metadata, a presence-driven
re-save — so each of those dragged the Game List ordering and label
forward. Now the record seeds createdAt and updatedAt only on first
sight; afterwards updatedAt tracks Moves alone. The winning move is
itself a move, so completion still advances it, but a bare cursor move
no longer does. updatedAt is a local display column — it is never
written back to the Game record — so the record's own modificationDate
still reflects every change.

This commit also adds diagnoseOrphanZones_v1, a read-only pass that runs
at startup and logs private `game-` zones that exist on the server but
have no local GameEntity and no Game record — stranded relics of dev
builds or deletions that predate enqueueDeleteGame. It deletes nothing;
it records what a future guarded cleanup would target so the candidate
list can be reviewed first.

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

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 21++++++++++++---------
MCrossmate/Sync/SyncEngine.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 92 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -719,18 +719,21 @@ enum RecordSerializer { // the clobbered value, permanently losing it server-side. guard !entity.hasPendingSave else { return entity } - // Seed createdAt/updatedAt from the server record so the library - // can order newly-arrived games. After that, Game records may lag - // behind fresher Moves timestamps, so never move updatedAt backward. + // Seed createdAt/updatedAt from the server record only on first sight, + // so a newly-arrived game has something for the library to order by. + // After that, the library timestamp tracks *gameplay* (Moves) alone. + // The Game record's modificationDate advances on non-gameplay writes + // too — engagement/push credentials, share metadata, the notification + // field — so adopting it on every fetch made a game look freshly + // "updated" when nothing was played (e.g. a peer, or this device, + // merely moved the cursor or re-minted a credential). Gameplay flows + // through the Moves path, which sets updatedAt independently; the + // winning move is itself a move, so completion still advances it. if entity.createdAt == nil { entity.createdAt = record.creationDate ?? Date() } - if let modificationDate = record.modificationDate { - if entity.updatedAt.map({ $0 < modificationDate }) ?? true { - entity.updatedAt = modificationDate - } - } else if entity.updatedAt == nil { - entity.updatedAt = Date() + if entity.updatedAt == nil { + entity.updatedAt = record.modificationDate ?? Date() } entity.title = record["title"] as? String ?? entity.title diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -329,6 +329,7 @@ actor SyncEngine { Task { await purgeStaleHailPings_v1() } Task { await purgeDebugPreviewFriends_v1() } Task { await purgeLegacyPlayPings_v1() } + Task { await diagnoseOrphanZones_v1() } } private func ensureDatabaseSubscriptions() async { @@ -828,6 +829,85 @@ actor SyncEngine { } } + /// Read-only diagnostic — lists, but does **not** delete, stranded private + /// game zones. An orphan is a `game-<uuid>` zone that exists on the private + /// server but has no local `GameEntity` and no `Game` record: a relic of a + /// dev build or a pre-`enqueueDeleteGame` deletion. Logs the candidate zone + /// names so a captured diagnostic dump shows exactly what a future cleanup + /// would remove before the destructive version ships. + /// + /// These are the guards that cleanup would delete under, applied here so the + /// list is faithful: + /// - private database only (never the shared DB — those zones aren't ours), + /// - `game-` prefix only — skips `_defaultZone` and the account/friend + /// zones, which legitimately carry no `Game` record, + /// - no local `GameEntity` for the zone — the race guard: a game mid-creation + /// (here or on a sibling) has, or is about to have, a local row, so it is + /// never mistaken for an orphan. + func diagnoseOrphanZones_v1() async { + let database = container.privateCloudDatabase + do { + let gameZones = try await database.allRecordZones() + .map(\.zoneID) + .filter { $0 != .default && $0.zoneName.hasPrefix("game-") } + guard !gameZones.isEmpty else { return } + + let knownZoneNames = localGameZoneNames() + // Match what the live catch-up queries use; a bare TRUEPREDICATE is + // not reliably accepted for a zone query. + let anyRecord = NSPredicate( + format: "modificationDate > %@", + Date(timeIntervalSince1970: 0) as NSDate + ) + + var orphans: [String] = [] + for zoneID in gameZones where !knownZoneNames.contains(zoneID.zoneName) { + let games = try await queryRecords( + type: "Game", + database: database, + zoneID: zoneID, + predicate: anyRecord, + desiredKeys: [] + ) + if games.isEmpty { orphans.append(zoneID.zoneName) } + } + + if orphans.isEmpty { + await trace( + "orphan-zone diagnostic: none " + + "(private game-zones=\(gameZones.count))" + ) + } else { + await trace( + "orphan-zone diagnostic: \(orphans.count) orphan(s) of " + + "\(gameZones.count) private game-zone(s): " + + "[\(orphans.sorted().joined(separator: ", "))]" + ) + } + } catch { + await trace("orphan-zone diagnostic failed: \(describe(error))") + } + } + + /// Zone names of every locally-tracked game, regardless of scope or + /// access-revoked state — anything we hold a `GameEntity` for is a tracked + /// game, not an orphan. + private func localGameZoneNames() -> Set<String> { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + var names = Set<String>() + for entity in (try? ctx.fetch(req)) ?? [] { + if let zoneName = entity.ckZoneName { + names.insert(zoneName) + } else if let gameID = entity.id { + names.insert("game-\(gameID.uuidString)") + } + } + return names + } + } + /// One-shot local cleanup for pre-release debug friend rows whose /// `friend-debug-preview-*` zones no longer exist or have invalid /// participants. These rows are local metadata, but while present they