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:
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)
+ }
+}