crossmate

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

commit 740784d5cebbb7eb0f04fe414c0d2af9bebba167
parent 294def3a17a24bc352872b13b34fd973a4080d96
Author: Michael Camilleri <[email protected]>
Date:   Mon, 13 Apr 2026 08:39:20 +0900

Ensure game records are saved before square records

One of the problems with iCloud syncing is that a square update cannot
be saved to the database if its game record does not exist. This commit
address this by aiming to ensure game records are always saved first.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 4++++
MCrossmate/Sync/RecordSerializer.swift | 32++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
3 files changed, 103 insertions(+), 33 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -174,6 +174,8 @@ final class GameStore { } } + RecordSerializer.enqueueGamePending(for: entity, in: context) + try context.save() return gameID } @@ -289,6 +291,8 @@ final class GameStore { } } + RecordSerializer.enqueueGamePending(for: entity, in: context) + try context.save() return (entity, puzzle) } diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -133,6 +133,38 @@ enum RecordSerializer { return entity } + // MARK: - Pending change construction + + /// Enqueues a Game pending change for the given entity. Used both when a + /// new game is created locally and as a recovery step for games that were + /// created before the sync engine could push them. + static func enqueueGamePending( + for entity: GameEntity, + in context: NSManagedObjectContext + ) { + guard let gameID = entity.id else { return } + let recordName = recordName(forGameID: gameID) + + let payload = PendingChangePayload( + recordType: .game, + recordName: recordName, + title: entity.title, + puzzleSource: entity.puzzleSource, + completedAt: entity.completedAt + ) + + guard let jsonData = try? JSONEncoder().encode(payload), + let jsonString = String(data: jsonData, encoding: .utf8) + else { return } + + PendingChangeEntity.upsert( + recordName: recordName, + recordType: "Game", + payload: jsonString, + in: context + ) + } + // MARK: - System fields encode/decode static func encodeSystemFields(of record: CKRecord) -> Data? { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -132,6 +132,25 @@ actor SyncEngine { await trace("push: enter") + // Recovery: scan for any GameEntity that has a ckRecordName but no + // ckSystemFields (i.e., never successfully pushed) and enqueue a + // pending change for it. This catches games created before the sync + // engine could push them, so any cells referencing them have a + // parent on the server when their turn comes. + let recovered: Int = context.performAndWait { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "ckRecordName != nil AND ckSystemFields == nil") + guard let entities = try? context.fetch(request), !entities.isEmpty else { return 0 } + for entity in entities { + RecordSerializer.enqueueGamePending(for: entity, in: context) + } + try? context.save() + return entities.count + } + if recovered > 0 { + await trace("push: recovered \(recovered) un-pushed Game(s) into outbox") + } + while true { iteration += 1 await trace("push[\(iteration)]: draining outbox") @@ -177,8 +196,16 @@ actor SyncEngine { break } + // Sort Games before Cells so that within a single batch, parent + // records are saved before any child cell records that reference + // them. CloudKit rejects child saves whose parent doesn't exist + // on the server (errorCode=31, ValidatingReferenceFailure). + let orderedBatch = batch.sorted { lhs, rhs in + lhs.payload.recordType == .game && rhs.payload.recordType == .cell + } + // Build CKRecords - let records: [CKRecord] = batch.map { item in + let records: [CKRecord] = orderedBatch.map { item in switch item.payload.recordType { case .game: return RecordSerializer.gameRecord( @@ -195,7 +222,7 @@ actor SyncEngine { } } - for (i, item) in batch.enumerated() { + for (i, item) in orderedBatch.enumerated() { let kind = item.payload.recordType == .game ? "Game" : "Cell" let hasSF = item.systemFields != nil ? "yes" : "no" await trace("push[\(iteration)]: record[\(i)] \(kind) name=\(item.recordName) systemFields=\(hasSF)") @@ -212,39 +239,46 @@ actor SyncEngine { } await trace("push[\(iteration)]: pushRecords returned \(perRecordResults.count) result(s)") - var successes = 0 - var failures = 0 - var errorMessages: [String] = [] - - // Process results on the background context - context.performAndWait { - for (recordID, result) in perRecordResults { - let recordName = recordID.recordName - switch result { - case .success(let savedRecord): - successes += 1 - // Write ckSystemFields back to the entity - self.writeBackSystemFields( - record: savedRecord, - recordName: recordName, - in: context - ) - // Delete the PendingChange row - self.deletePendingChange(recordName: recordName, in: context) - - case .failure(let error): - failures += 1 - let changes = self.handlePushError( - error: error, - recordName: recordName, - in: context, - errorSink: &errorMessages - ) - serverWinsCellChanges.append(contentsOf: changes) + // Process results on the background context. Returns a tuple so + // we don't have to mutate captured locals from inside the closure. + let processed: (successes: Int, failures: Int, errors: [String], cellChanges: [RemoteCellChange]) = + context.performAndWait { + var successes = 0 + var failures = 0 + var errors: [String] = [] + var cellChanges: [RemoteCellChange] = [] + + for (recordID, result) in perRecordResults { + let recordName = recordID.recordName + switch result { + case .success(let savedRecord): + successes += 1 + self.writeBackSystemFields( + record: savedRecord, + recordName: recordName, + in: context + ) + self.deletePendingChange(recordName: recordName, in: context) + + case .failure(let error): + failures += 1 + let changes = self.handlePushError( + error: error, + recordName: recordName, + in: context, + errorSink: &errors + ) + cellChanges.append(contentsOf: changes) + } } + try? context.save() + return (successes, failures, errors, cellChanges) } - try? context.save() - } + + let successes = processed.successes + let failures = processed.failures + let errorMessages = processed.errors + serverWinsCellChanges.append(contentsOf: processed.cellChanges) await trace("push[\(iteration)]: processed \(successes) success / \(failures) failure") for msg in errorMessages {