commit c1c3a272b359d1308166b416930ed1c2cc445e82
parent ead9dbcfe6399be76913e3da4b48a73ee3fa094d
Author: Michael Camilleri <[email protected]>
Date: Wed, 15 Apr 2026 02:32:17 +0900
Sync deletion of games
Prior to this commit, deletion of games was not synchronised between
devices.
Co-Authored-By: Codex GPT 5.4 <[email protected]>
Diffstat:
4 files changed, 181 insertions(+), 24 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -188,8 +188,28 @@ final class GameStore {
currentEntity = nil
}
+ let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
+ for cellEntity in cellEntities {
+ if let recordName = cellEntity.ckRecordName {
+ RecordSerializer.enqueueDeletePending(
+ recordName: recordName,
+ recordType: .deletedCell,
+ in: context
+ )
+ }
+ }
+
+ if let recordName = entity.ckRecordName {
+ RecordSerializer.enqueueDeletePending(
+ recordName: recordName,
+ recordType: .deletedGame,
+ in: context
+ )
+ }
+
context.delete(entity)
try context.save()
+ notifyPendingChangesAvailable()
}
// MARK: - Resign a game
diff --git a/Crossmate/Sync/PendingChangePayload.swift b/Crossmate/Sync/PendingChangePayload.swift
@@ -7,6 +7,8 @@ struct PendingChangePayload: Codable {
enum RecordType: String, Codable {
case game = "Game"
case cell = "Cell"
+ case deletedGame = "DeletedGame"
+ case deletedCell = "DeletedCell"
}
let recordType: RecordType
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -181,6 +181,28 @@ enum RecordSerializer {
)
}
+ static func enqueueDeletePending(
+ recordName: String,
+ recordType: PendingChangePayload.RecordType,
+ in context: NSManagedObjectContext
+ ) {
+ let payload = PendingChangePayload(
+ recordType: recordType,
+ recordName: recordName
+ )
+
+ guard let jsonData = try? JSONEncoder().encode(payload),
+ let jsonString = String(data: jsonData, encoding: .utf8)
+ else { return }
+
+ PendingChangeEntity.upsert(
+ recordName: recordName,
+ recordType: recordType.rawValue,
+ 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
@@ -6,6 +6,17 @@ import Foundation
/// operations run on this actor's serial executor, keeping token reads and
/// writes race-free.
actor SyncEngine {
+ private enum PushResult {
+ case saved(CKRecord)
+ case deleted
+ case failed(Error)
+ }
+
+ private struct DeletedRecord {
+ let recordID: CKRecord.ID
+ let recordType: String?
+ }
+
let container: CKContainer
let privateDatabase: CKDatabase
let persistence: PersistenceController
@@ -233,16 +244,24 @@ 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).
+ // Sort saves parent-first and deletes child-first. CloudKit
+ // rejects child saves whose parent doesn't exist; deleting child
+ // records first avoids leaving orphaned cells behind a deleted
+ // game.
let orderedBatch = batch.sorted { lhs, rhs in
- lhs.payload.recordType == .game && rhs.payload.recordType == .cell
+ switch (lhs.payload.recordType, rhs.payload.recordType) {
+ case (.game, .cell), (.game, .deletedCell), (.game, .deletedGame):
+ return true
+ case (.cell, .deletedCell), (.cell, .deletedGame):
+ return true
+ case (.deletedCell, .deletedGame):
+ return true
+ default:
+ return false
+ }
}
- // Build CKRecords
- let records: [CKRecord] = orderedBatch.map { item in
+ let recordsToSave: [CKRecord] = orderedBatch.compactMap { item in
switch item.payload.recordType {
case .game:
return RecordSerializer.gameRecord(
@@ -256,20 +275,34 @@ actor SyncEngine {
zone: zoneID,
systemFields: item.systemFields
)
+ case .deletedGame, .deletedCell:
+ return nil
+ }
+ }
+
+ let recordIDsToDelete: [CKRecord.ID] = orderedBatch.compactMap { item in
+ switch item.payload.recordType {
+ case .deletedGame, .deletedCell:
+ return CKRecord.ID(recordName: item.recordName, zoneID: zoneID)
+ case .game, .cell:
+ return nil
}
}
for (i, item) in orderedBatch.enumerated() {
- let kind = item.payload.recordType == .game ? "Game" : "Cell"
+ let kind = item.payload.recordType.rawValue
let hasSF = item.systemFields != nil ? "yes" : "no"
await trace("push[\(iteration)]: record[\(i)] \(kind) name=\(item.recordName) systemFields=\(hasSF)")
}
// Push via CKModifyRecordsOperation
- await trace("push[\(iteration)]: calling pushRecords (\(records.count) records)")
- let perRecordResults: [CKRecord.ID: Result<CKRecord, Error>]
+ await trace("push[\(iteration)]: calling pushRecords save=\(recordsToSave.count) delete=\(recordIDsToDelete.count)")
+ let perRecordResults: [CKRecord.ID: PushResult]
do {
- perRecordResults = try await pushRecords(records)
+ perRecordResults = try await pushRecords(
+ recordsToSave: recordsToSave,
+ recordIDsToDelete: recordIDsToDelete
+ )
} catch {
await trace("push[\(iteration)]: pushRecords THREW: \(describe(error))")
throw error
@@ -288,7 +321,7 @@ actor SyncEngine {
for (recordID, result) in perRecordResults {
let recordName = recordID.recordName
switch result {
- case .success(let savedRecord):
+ case .saved(let savedRecord):
successes += 1
self.writeBackSystemFields(
record: savedRecord,
@@ -297,7 +330,16 @@ actor SyncEngine {
)
self.deletePendingChange(recordName: recordName, in: context)
- case .failure(let error):
+ case .deleted:
+ successes += 1
+ self.deletePendingChange(recordName: recordName, in: context)
+
+ case .failed(let error):
+ if self.isAlreadyDeleted(error) {
+ successes += 1
+ self.deletePendingChange(recordName: recordName, in: context)
+ continue
+ }
failures += 1
let changes = self.handlePushError(
error: error,
@@ -380,23 +422,30 @@ actor SyncEngine {
SyncStateEntity.current(in: context).decodedPrivateZoneToken
}
- let incomingRecords = try await fetchZoneChanges(token: zoneToken, context: context)
+ let zoneChanges = try await fetchZoneChanges(token: zoneToken, context: context)
+ let incomingRecords = zoneChanges.changedRecords
+ let deletedRecords = zoneChanges.deletedRecords
await trace("fetch: incomingRecords count=\(incomingRecords.count)")
for record in incomingRecords {
await trace("fetch: record \(record.recordType) name=\(record.recordID.recordName)")
}
+ await trace("fetch: deletedRecords count=\(deletedRecords.count)")
+ for deleted in deletedRecords {
+ await trace("fetch: deleted \(deleted.recordType ?? "unknown") name=\(deleted.recordID.recordName)")
+ }
- guard !incomingRecords.isEmpty else { return }
+ guard !incomingRecords.isEmpty || !deletedRecords.isEmpty else { return }
// Extract cell changes before applying (reads directly from CKRecords)
let cellChanges = incomingRecords.compactMap { Self.extractCellChange(from: $0) }
// Step 3: Apply incoming records to Core Data
context.performAndWait {
+ self.applyDeletedRecords(deletedRecords, in: context)
self.applyIncomingRecords(incomingRecords, in: context)
try? context.save()
}
- await trace("fetch: applied \(incomingRecords.count) record(s) to Core Data")
+ await trace("fetch: applied \(incomingRecords.count) changed and \(deletedRecords.count) deleted record(s) to Core Data")
// Step 4: Route cell changes through the single inbox
if let onRemoteCellChanges, !cellChanges.isEmpty {
@@ -441,7 +490,7 @@ actor SyncEngine {
private func fetchZoneChanges(
token: CKServerChangeToken?,
context: NSManagedObjectContext
- ) async throws -> [CKRecord] {
+ ) async throws -> (changedRecords: [CKRecord], deletedRecords: [DeletedRecord]) {
let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
options.previousServerChangeToken = token
@@ -452,14 +501,19 @@ actor SyncEngine {
operation.qualityOfService = .utility
var incomingRecords: [CKRecord] = []
+ var deletedRecords: [DeletedRecord] = []
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord], Error>) in
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(changedRecords: [CKRecord], deletedRecords: [DeletedRecord]), Error>) in
operation.recordWasChangedBlock = { _, result in
if case .success(let record) = result {
incomingRecords.append(record)
}
}
+ operation.recordWithIDWasDeletedBlock = { recordID, recordType in
+ deletedRecords.append(DeletedRecord(recordID: recordID, recordType: recordType))
+ }
+
operation.recordZoneFetchResultBlock = { _, result in
switch result {
case .success(let (serverToken, _, _)):
@@ -476,7 +530,7 @@ actor SyncEngine {
operation.fetchRecordZoneChangesResultBlock = { result in
switch result {
case .success:
- continuation.resume(returning: incomingRecords)
+ continuation.resume(returning: (incomingRecords, deletedRecords))
case .failure(let error):
continuation.resume(throwing: error)
}
@@ -506,19 +560,73 @@ actor SyncEngine {
}
}
+ private nonisolated func applyDeletedRecords(
+ _ records: [DeletedRecord],
+ in context: NSManagedObjectContext
+ ) {
+ for deleted in records {
+ let recordName = deleted.recordID.recordName
+ switch deleted.recordType {
+ case "Cell":
+ deleteObject(entityName: "CellEntity", recordName: recordName, in: context)
+ case "Game":
+ deleteObject(entityName: "GameEntity", recordName: recordName, in: context)
+ default:
+ if recordName.hasPrefix("cell-") {
+ deleteObject(entityName: "CellEntity", recordName: recordName, in: context)
+ } else if recordName.hasPrefix("game-") {
+ deleteObject(entityName: "GameEntity", recordName: recordName, in: context)
+ }
+ }
+ }
+ }
+
+ private nonisolated func deleteObject(
+ entityName: String,
+ recordName: String,
+ in context: NSManagedObjectContext
+ ) {
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ request.fetchLimit = 1
+ if let object = try? context.fetch(request).first {
+ context.delete(object)
+ }
+ }
+
// MARK: - Push helpers
- private func pushRecords(_ records: [CKRecord]) async throws -> [CKRecord.ID: Result<CKRecord, Error>] {
- let operation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
+ private func pushRecords(
+ recordsToSave: [CKRecord],
+ recordIDsToDelete: [CKRecord.ID]
+ ) async throws -> [CKRecord.ID: PushResult] {
+ let operation = CKModifyRecordsOperation(
+ recordsToSave: recordsToSave,
+ recordIDsToDelete: recordIDsToDelete
+ )
operation.savePolicy = .changedKeys
operation.isAtomic = false
operation.qualityOfService = .utility
- var perRecordResults: [CKRecord.ID: Result<CKRecord, Error>] = [:]
+ var perRecordResults: [CKRecord.ID: PushResult] = [:]
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord.ID: Result<CKRecord, Error>], Error>) in
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord.ID: PushResult], Error>) in
operation.perRecordSaveBlock = { recordID, result in
- perRecordResults[recordID] = result
+ switch result {
+ case .success(let record):
+ perRecordResults[recordID] = .saved(record)
+ case .failure(let error):
+ perRecordResults[recordID] = .failed(error)
+ }
+ }
+
+ operation.perRecordDeleteBlock = { recordID, result in
+ switch result {
+ case .success:
+ perRecordResults[recordID] = .deleted
+ case .failure(let error):
+ perRecordResults[recordID] = .failed(error)
+ }
}
operation.modifyRecordsResultBlock = { result in
@@ -578,6 +686,11 @@ actor SyncEngine {
}
}
+ private nonisolated func isAlreadyDeleted(_ error: Error) -> Bool {
+ guard let ckError = error as? CKError else { return false }
+ return ckError.code == .unknownItem
+ }
+
/// Returns any `RemoteCellChange` values that resulted from the server
/// winning a conflict (so the caller can route them through the single
/// inbox on the main actor).