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:
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 {