crossmate

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

commit 98a6960605bc992ac070d7dae996293d0c881e83
parent a65825bc8d781fc2ccce66b7ee93d6af18dff63a
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 08:21:18 +0900

Fix silent record drops in inbound CloudKit sync

Prior to this commit, two defects in the inbound sync path could leave a device
permanently missing records while still reporting 'Pending Changes: 0':

1. Resetting sync state only cleared the persisted
   CKSyncEngine.State.Serialization in Core Data; the running engines kept their
   tokens in memory, and the next stateUpdate event re-saved those tokens back
   into the cleared field. Force-quitting between reset and the next sync did
   not help because the rewrite ran on the same foreground tick.

2. The functions applyMoveRecord, applyPlayerRecord, and applySnapshotRecord
   guard-returned when the parent GameEntity was not yet in Core Data, but
   CKSyncEngine paginates the initial pull of a fresh device across multiple
   FetchedRecordZoneChanges events with no guarantee that a zone's Game record
   arrives in the first batch — any Move, Snapshot, or Player record processed
   before its Game record was silently dropped while the engine still advanced
   its server change token, leaving the missing rows invisible to subsequent
   incremental fetches.

This commit makes resetSyncState replace the in-memory privateEngine and
sharedEngine instances in addition to clearing the persisted state, so the new
engines start with no tokens, walk every zone from scratch on the next fetch,
and re-enqueue locally-unconfirmed moves via the existing
enqueueUnconfirmedMoves path. pendingPings and the loggedFirstSharedPushPayload
flag are reset in the same step so the post-reset engine state matches a cold
start.

For the apply path, a new RecordSerializer.ensureGameEntity helper fetches
GameEntity by ckRecordName and creates an unpopulated stub if none is found,
with id / zone / scope derivable from the inbound record's gameID and zoneID.
The stub uses empty title and puzzleSource so GameSummary.init? filters it out
of the library until applyGameRecord arrives with the real metadata and updates
the same row by ckRecordName. The three apply functions now route through
ensureGameEntity, eliminating the guard-return and the record.parent reference
fallback that was redundant with the parsed gameID. EnsureGameEntityTests pins
the invariant: stub creation, no duplicates on repeated calls, scope derived
from zone owner, and follow-up applyGameRecord matching the stub by
ckRecordName.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Sync/RecordSerializer.swift | 35+++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 57+++++++++++++++------------------------------------------
ATests/Unit/Sync/EnsureGameEntityTests.swift | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 172 insertions(+), 42 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; }; 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; + 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; @@ -153,6 +154,7 @@ 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; + 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; }; @@ -345,6 +347,7 @@ isa = PBXGroup; children = ( 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, + 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, ); @@ -489,6 +492,7 @@ buildActionMask = 2147483647; files = ( A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, + 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */, diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -341,6 +341,41 @@ enum RecordSerializer { // MARK: - Applying incoming CKRecords to Core Data + /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if + /// none exists yet. Move, Snapshot, and Player records can arrive in a + /// different fetch batch than the Game record that created the zone — on + /// a fresh device CKSyncEngine paginates the initial pull and there is no + /// guarantee that Game comes first. Without this stub the parent lookup + /// fails, the inbound record is dropped, but CKSyncEngine still advances + /// its change token, so the gap is invisible until the next state reset. + /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?` + /// filters it out of the library until `applyGameRecord` arrives with + /// the real metadata and updates the same row (matched by `ckRecordName`). + static func ensureGameEntity( + forGameID gameID: UUID, + zoneID: CKRecordZone.ID, + in ctx: NSManagedObjectContext + ) -> GameEntity { + let name = recordName(forGameID: gameID) + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", name) + req.fetchLimit = 1 + if let existing = try? ctx.fetch(req).first { return existing } + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.ckRecordName = name + entity.ckZoneName = zoneID.zoneName + let ownerName = zoneID.ownerName + let isOwner = ownerName == CKCurrentUserDefaultName + entity.ckZoneOwnerName = isOwner ? nil : ownerName + entity.databaseScope = isOwner ? 0 : 1 + entity.title = "" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + return entity + } + static func applyGameRecord( _ record: CKRecord, to context: NSManagedObjectContext, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -691,20 +691,11 @@ actor SyncEngine { if let existing = try? ctx.fetch(req).first { entity = existing } else { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - if let parentRef = record.parent { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - parentRef.recordID.recordName - ) - } else { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - RecordSerializer.recordName(forGameID: move.gameID) - ) - } - gameReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gameReq).first else { return } + let game = RecordSerializer.ensureGameEntity( + forGameID: move.gameID, + zoneID: record.recordID.zoneID, + in: ctx + ) entity = MoveEntity(context: ctx) entity.game = game } @@ -750,20 +741,11 @@ actor SyncEngine { if let existing = try? ctx.fetch(req).first { entity = existing } else { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - if let parentRef = record.parent { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - parentRef.recordID.recordName - ) - } else { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - RecordSerializer.recordName(forGameID: gameID) - ) - } - gameReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gameReq).first else { return } + let game = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: record.recordID.zoneID, + in: ctx + ) entity = PlayerEntity(context: ctx) entity.game = game } @@ -803,20 +785,11 @@ actor SyncEngine { if let existing = try? ctx.fetch(req).first { entity = existing } else { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - if let parentRef = record.parent { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - parentRef.recordID.recordName - ) - } else { - gameReq.predicate = NSPredicate( - format: "ckRecordName == %@", - RecordSerializer.recordName(forGameID: snapshot.gameID) - ) - } - gameReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gameReq).first else { return } + let game = RecordSerializer.ensureGameEntity( + forGameID: snapshot.gameID, + zoneID: record.recordID.zoneID, + in: ctx + ) entity = SnapshotEntity(context: ctx) entity.game = game } diff --git a/Tests/Unit/Sync/EnsureGameEntityTests.swift b/Tests/Unit/Sync/EnsureGameEntityTests.swift @@ -0,0 +1,118 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the lazy-parent-creation invariant that prevents Move / Snapshot / +/// Player records from being silently dropped when they arrive in a different +/// CKSyncEngine fetch batch than the Game record that created the zone. +@Suite("EnsureGameEntity") +@MainActor +struct EnsureGameEntityTests { + + @Test("creates a hidden stub when no GameEntity exists") + func createsHiddenStub() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneID = RecordSerializer.zoneID(for: gameID) + + let entity = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: zoneID, + in: ctx + ) + + #expect(entity.id == gameID) + #expect(entity.ckRecordName == "game-\(gameID.uuidString)") + #expect(entity.ckZoneName == zoneID.zoneName) + #expect(entity.databaseScope == 0) + #expect(entity.ckZoneOwnerName == nil) + #expect(entity.title == "") + // Empty puzzleSource is what GameSummary.init? uses to filter the row + // out of the library until the real Game record arrives. + #expect(entity.puzzleSource == "") + } + + @Test("returns existing entity, no duplicate") + func returnsExisting() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneID = RecordSerializer.zoneID(for: gameID) + + let first = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: zoneID, + in: ctx + ) + first.title = "preserved" + let second = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: zoneID, + in: ctx + ) + + #expect(first.objectID == second.objectID) + #expect(second.title == "preserved") + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let all = try ctx.fetch(req) + #expect(all.count == 1) + } + + @Test("derives shared scope from non-default zone owner") + func sharedScope() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_someOwnerID") + + let entity = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: zoneID, + in: ctx + ) + + #expect(entity.databaseScope == 1) + #expect(entity.ckZoneOwnerName == "_someOwnerID") + } + + @Test("later Game record populates the same stub row") + func gameRecordPopulatesStub() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneID = RecordSerializer.zoneID(for: gameID) + + let stub = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: zoneID, + in: ctx + ) + let stubObjectID = stub.objectID + + // Simulate the Game record arriving in a later batch. applyGameRecord + // matches by ckRecordName via fetchOrCreate, so the stub row should be + // updated rather than a new row created. + let recordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: zoneID + ) + let record = CKRecord(recordType: "Game", recordID: recordID) + record["title"] = "Real Title" as CKRecordValue + + let updated = RecordSerializer.applyGameRecord(record, to: ctx) + + #expect(updated.objectID == stubObjectID) + #expect(updated.title == "Real Title") + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let all = try ctx.fetch(req) + #expect(all.count == 1) + } +}