crossmate

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

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:
MCrossmate/Persistence/GameStore.swift | 20++++++++++++++++++++
MCrossmate/Sync/PendingChangePayload.swift | 2++
MCrossmate/Sync/RecordSerializer.swift | 22++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
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).