crossmate

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

commit 45f5126dc555267c19ca165f8289b88fbfde905e
parent fa99ae98e701a58dff6e4392a43137191e91c76d
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 17:20:09 +0900

Replay nicknames after friendship bootstrap

When a second device learned about a new crossmate after the user's
private nickname Decision had already been fetched, that first nickname
could be lost locally. The account-zone Decision did not create a friend
row on its own, and CKSyncEngine would advance past the record even
though there was nothing to apply yet.

This commit has friendship bootstrap look up the deterministic
decision-nickname-<authorID> record after the FriendEntity is saved and
replay it through the normal Decision applier. Missing records are
treated as the common no-nickname case, while existing version checks
still protect newer local nicknames from being overwritten.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Sync/FriendController.swift | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
ATests/Unit/Sync/FriendControllerNicknameReplayTests.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 151 insertions(+), 1 deletion(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; }; 91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; }; 924B29C1EEB29F849A6824C3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74C8886A66F0877858A67D62 /* AboutView.swift */; }; + 931431F8052FC58768C9BC26 /* FriendControllerNicknameReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */; }; 93DB3DD9A8EE994B92E7C084 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED48AD9C3A7A113D101BBD21 /* GridView.swift */; }; 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3D29B227D2B0E699423C48 /* Journal.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; @@ -268,6 +269,7 @@ 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; }; 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; }; + 2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendControllerNicknameReplayTests.swift; sourceTree = "<group>"; }; 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = "<group>"; }; 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; @@ -710,6 +712,7 @@ 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, + 2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */, B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, 800CCFBE90554F287E765755 /* FriendZoneTests.swift */, 7B5A8118AC2FE60D877F1D29 /* GamePushCredentialsTests.swift */, @@ -923,6 +926,7 @@ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, + 931431F8052FC58768C9BC26 /* FriendControllerNicknameReplayTests.swift in Sources */, 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */, 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */, 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */, diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -20,19 +20,23 @@ final class FriendController { private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor? private let eventLog: EventLog? + private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord init( container: CKContainer, persistence: PersistenceController, syncEngine: SyncEngine, syncMonitor: SyncMonitor? = nil, - eventLog: EventLog? = nil + eventLog: EventLog? = nil, + fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil ) { self.container = container self.persistence = persistence self.syncEngine = syncEngine self.syncMonitor = syncMonitor self.eventLog = eventLog + self.fetchAccountDecisionRecord = fetchAccountDecisionRecord + ?? { try await container.privateCloudDatabase.record(for: $0) } } enum FriendError: Error { @@ -556,8 +560,53 @@ final class FriendController { if entity.createdAt == nil { entity.createdAt = Date() } do { try ctx.save() + Task { [weak self] in + await self?.applyAccountNicknameDecisionIfPresent(for: authorID) + } } catch { eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error") } } + + /// A sibling can set a nickname before this device has bootstrapped the + /// `FriendEntity`. CKSyncEngine will then advance past the account-zone + /// Decision without applying it because there is no friend row yet. After + /// bootstrap creates the row, fetch the deterministic Decision directly + /// and replay it once. + func applyAccountNicknameDecisionIfPresent(for friendAuthorID: String) async { + let recordName = RecordSerializer.decisionRecordName( + kind: RecordSerializer.nicknameDecisionKind, + key: friendAuthorID + ) + let recordID = CKRecord.ID( + recordName: recordName, + zoneID: RecordSerializer.accountZoneID + ) + do { + let record = try await fetchAccountDecisionRecord(recordID) + let ctx = persistence.viewContext + let wrote = RecordSerializer.applyDecisionRecord( + record, + to: ctx, + localAuthorID: nil, + databaseScope: 0 + ) + if wrote { + try ctx.save() + FriendEntity.rebuildNicknameDirectory(in: ctx) + eventLog?.note( + "FriendController: replayed nickname decision \(recordName)", + level: "info" + ) + } + } catch let error as CKError + where error.code == .unknownItem || error.code == .zoneNotFound { + // Most friendships will not have a private nickname yet. + } catch { + eventLog?.note( + "FriendController: nickname decision replay failed for \(friendAuthorID) — \(error)", + level: "error" + ) + } + } } diff --git a/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift b/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift @@ -0,0 +1,97 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("FriendController nickname replay", .serialized) +@MainActor +struct FriendControllerNicknameReplayTests { + @Test("Post-bootstrap replay applies an existing account nickname decision") + func replayAppliesExistingNicknameDecision() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = FriendEntity(context: ctx) + friend.authorID = "_bob" + friend.pairKey = FriendZone.pairKey("_alice", "_bob") + friend.friendZoneName = FriendZone.zoneName(pairKey: friend.pairKey!) + friend.friendZoneOwnerName = CKCurrentUserDefaultName + friend.databaseScope = 0 + friend.createdAt = Date() + try ctx.save() + + let fetchedZone = CKRecordZone.ID( + zoneName: RecordSerializer.accountZoneID.zoneName, + ownerName: "_alice" + ) + let decision = RecordSerializer.decisionRecord( + kind: RecordSerializer.nicknameDecisionKind, + key: "_bob", + payload: "Bobby", + zone: fetchedZone, + version: 1 + ) + var requestedRecordID: CKRecord.ID? + let controller = FriendController( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence, + syncEngine: SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence + ), + fetchAccountDecisionRecord: { recordID in + requestedRecordID = recordID + return decision + } + ) + + await controller.applyAccountNicknameDecisionIfPresent(for: "_bob") + + #expect(requestedRecordID?.recordName == RecordSerializer.decisionRecordName( + kind: RecordSerializer.nicknameDecisionKind, + key: "_bob" + )) + #expect(requestedRecordID?.zoneID.zoneName == RecordSerializer.accountZoneID.zoneName) + #expect(friend.nickname == "Bobby") + #expect(friend.nicknameVersion == 1) + } + + @Test("Post-bootstrap replay keeps a newer local nickname") + func replayKeepsNewerLocalNickname() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = FriendEntity(context: ctx) + friend.authorID = "_bob" + friend.pairKey = FriendZone.pairKey("_alice", "_bob") + friend.friendZoneName = FriendZone.zoneName(pairKey: friend.pairKey!) + friend.friendZoneOwnerName = CKCurrentUserDefaultName + friend.databaseScope = 0 + friend.createdAt = Date() + friend.nickname = "Robert" + friend.nicknameVersion = 2 + try ctx.save() + + let decision = RecordSerializer.decisionRecord( + kind: RecordSerializer.nicknameDecisionKind, + key: "_bob", + payload: "Bobby", + zone: RecordSerializer.accountZoneID, + version: 1 + ) + let controller = FriendController( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence, + syncEngine: SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence + ), + fetchAccountDecisionRecord: { _ in decision } + ) + + await controller.applyAccountNicknameDecisionIfPresent(for: "_bob") + + #expect(friend.nickname == "Robert") + #expect(friend.nicknameVersion == 2) + } +}