crossmate

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

commit 1f1777c8b879b7c0131a35e7347e831aa60f1e34
parent 656995e45142d659fd4459b1840a1ae8c01f399f
Author: Michael Camilleri <[email protected]>
Date:   Sun, 24 May 2026 04:16:19 +0900

Surface Core Data save failures in the diagnostics log

This commit adds some logging after a review of the `try? ctx.save()` sites
that were silently dropping write failures. Each one now catches the error and
routes it into the diagnostics log so that a missed save is visible rather than
invisible.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 30+++++++++++++++++++++---------
MCrossmate/Persistence/PersistenceController.swift | 18++++++++++++++++--
MCrossmate/Services/AppServices.swift | 38++++++++++++++++++++++++++++++--------
MCrossmate/Sync/FriendController.swift | 17++++++++++++++---
MCrossmate/Sync/SyncEngine.swift | 12++++++++++--
MCrossmate/Views/GameListView.swift | 7++++++-
6 files changed, 97 insertions(+), 25 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -236,13 +236,16 @@ final class GameStore { @ObservationIgnored var onUnreadOtherMovesChanged: (() -> Void)? + private let eventLog: EventLog? + init( persistence: PersistenceController, movesUpdater: MovesUpdater, authorIDProvider: @escaping @MainActor () -> String?, onGameCreated: @escaping (String) -> Void, onGameUpdated: @escaping (String) -> Void, - onGameDeleted: @escaping (GameCloudDeletion) -> Void + onGameDeleted: @escaping (GameCloudDeletion) -> Void, + eventLog: EventLog? = nil ) { self.persistence = persistence self.movesUpdater = movesUpdater @@ -250,6 +253,15 @@ final class GameStore { self.onGameCreated = onGameCreated self.onGameUpdated = onGameUpdated self.onGameDeleted = onGameDeleted + self.eventLog = eventLog + } + + private func saveContext(_ label: String) { + do { + try context.save() + } catch { + eventLog?.note("GameStore: \(label) save failed — \(error)", level: "error") + } } enum LoadError: Error { @@ -345,7 +357,7 @@ final class GameStore { } if context.hasChanges { - try? context.save() + saveContext("mergeRemoteMoves") } onUnreadOtherMovesChanged?() } @@ -528,7 +540,7 @@ final class GameStore { // resignations and drive the directed `.win` ping's body. entity.completedBy = authorIDProvider() entity.hasPendingSave = true - try? context.save() + saveContext("markCompleted") if let ckName = entity.ckRecordName { onGameUpdated(ckName) } @@ -555,7 +567,7 @@ final class GameStore { for entity in (try? context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity"))) ?? [] { context.delete(entity) } - try? context.save() + saveContext("resetLocalState") currentGame = nil currentMutator = nil currentEntity = nil @@ -632,7 +644,7 @@ final class GameStore { } guard entity.lastReadOtherMoveAt != readAt else { return false } entity.lastReadOtherMoveAt = readAt - try? context.save() + saveContext("updateReadAt") onUnreadOtherMovesChanged?() return true } @@ -642,7 +654,7 @@ final class GameStore { guard isShared, let latest = entity.latestOtherMoveAt else { return } if (entity.lastReadOtherMoveAt ?? .distantPast) < latest { entity.lastReadOtherMoveAt = latest - try? context.save() + saveContext("markOtherMovesRead") onUnreadOtherMovesChanged?() } } @@ -753,7 +765,7 @@ final class GameStore { entity.hasPushPending = true entity.hasPendingSave = true entity.populateCachedSummaryFields(from: puzzle) - try? context.save() + saveContext("upgradePuzzleSource") if let ckName = entity.ckRecordName { onGameUpdated(ckName) } @@ -769,7 +781,7 @@ final class GameStore { request.fetchLimit = 1 guard let entity = try? context.fetch(request).first else { return } entity.puzzleCmVersion = Int64(XD.currentCmVersion) - try? context.save() + saveContext("bumpPuzzleCmVersion") } private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) { @@ -819,7 +831,7 @@ final class GameStore { private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) { Self.applyCellCache(to: gameEntity, from: grid, in: context) - try? context.save() + saveContext("updateCellCache") } /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -12,7 +12,10 @@ final class PersistenceController { var viewContext: NSManagedObjectContext { container.viewContext } - init(inMemory: Bool = false) { + private let eventLog: EventLog? + + init(inMemory: Bool = false, eventLog: EventLog? = nil) { + self.eventLog = eventLog container = NSPersistentContainer( name: "CrossmateModel", managedObjectModel: Self.sharedModel @@ -71,7 +74,18 @@ final class PersistenceController { let xd = try? XD.parse(source) else { continue } entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) } - if bg.hasChanges { try? bg.save() } + if bg.hasChanges { + do { + try bg.save() + } catch { + Task { @MainActor [weak self] in + self?.eventLog?.note( + "PersistenceController: backfillCachedSummaryFields save failed — \(error)", + level: "error" + ) + } + } + } } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -103,12 +103,12 @@ final class AppServices { init() { let preferences = PlayerPreferences() self.preferences = preferences - let persistence = PersistenceController() + let eventLog = EventLog() + self.eventLog = eventLog + let persistence = PersistenceController(eventLog: eventLog) self.persistence = persistence let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) self.syncEngine = syncEngine - let eventLog = EventLog() - self.eventLog = eventLog self.syncMonitor = SyncMonitor(log: eventLog) self.nytAuth = NYTAuthService() self.driveMonitor = DriveMonitor() @@ -184,7 +184,8 @@ final class AppServices { onGameDeleted: { [preferences] deletion in guard preferences.isICloudSyncEnabled else { return } onGameDeletedHandler(deletion) - } + }, + eventLog: eventLog ) self.store = store @@ -214,7 +215,8 @@ final class AppServices { container: self.ckContainer, persistence: persistence, syncEngine: syncEngine, - syncMonitor: self.syncMonitor + syncMonitor: self.syncMonitor, + eventLog: eventLog ) self.cloudService = CloudService( container: self.ckContainer, @@ -1164,7 +1166,15 @@ final class AppServices { if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) } } - if ctx.hasChanges { try? ctx.save() } + if ctx.hasChanges { + do { + try ctx.save() + } catch { + Task { @MainActor [weak self] in + self?.eventLog.note("AppServices: applyInvitePings save failed — \(error)", level: "error") + } + } + } } } @@ -1183,7 +1193,13 @@ final class AppServices { let invites = (try? ctx.fetch(req)) ?? [] guard !invites.isEmpty else { return } for invite in invites { ctx.delete(invite) } - if ctx.hasChanges { try? ctx.save() } + if ctx.hasChanges { + do { + try ctx.save() + } catch { + eventLog.note("AppServices: removePendingInvite save failed — \(error)", level: "error") + } + } } /// Accepts a pending game invite: fetches the share metadata, joins via @@ -1242,7 +1258,13 @@ final class AppServices { } ctx.delete(invite) } - if ctx.hasChanges { try? ctx.save() } + if ctx.hasChanges { + do { + try ctx.save() + } catch { + eventLog.note("AppServices: deleteInviteAndPing save failed — \(error)", level: "error") + } + } } /// Blocks a collaborator: marks the friendship blocked and tears down the diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -19,17 +19,20 @@ final class FriendController { private let persistence: PersistenceController private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor? + private let eventLog: EventLog? init( container: CKContainer, persistence: PersistenceController, syncEngine: SyncEngine, - syncMonitor: SyncMonitor? = nil + syncMonitor: SyncMonitor? = nil, + eventLog: EventLog? = nil ) { self.container = container self.persistence = persistence self.syncEngine = syncEngine self.syncMonitor = syncMonitor + self.eventLog = eventLog } enum FriendError: Error { @@ -264,7 +267,11 @@ final class FriendController { let zoneName = friend.friendZoneName let ownerName = friend.friendZoneOwnerName friend.isBlocked = true - try? ctx.save() + do { + try ctx.save() + } catch { + eventLog?.note("FriendController: blockAndTeardown save failed — \(error)", level: "error") + } // Make the block authoritative across the user's own devices. Written // before the channel teardown so the durable fact survives even a @@ -461,7 +468,11 @@ final class FriendController { entity.friendZoneOwnerName = zoneOwnerName entity.databaseScope = databaseScope if entity.createdAt == nil { entity.createdAt = Date() } - try? ctx.save() + do { + try ctx.save() + } catch { + eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error") + } } private func displayNameFromPlayer(gameID: UUID, authorID: String) -> String? { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -703,7 +703,11 @@ actor SyncEngine { let entity = SyncStateEntity.current(in: ctx) entity.ckPrivateEngineState = nil entity.ckSharedEngineState = nil - try? ctx.save() + do { + try ctx.save() + } catch { + trace("resetSyncState ctx.save failed — \(error)") + } } privateEngine = CKSyncEngine(CKSyncEngine.Configuration( database: container.privateCloudDatabase, @@ -751,7 +755,11 @@ actor SyncEngine { } else { entity.ckSharedEngineState = data } - try? ctx.save() + do { + try ctx.save() + } catch { + trace("saveEngineState ctx.save failed — \(error)") + } } } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -10,6 +10,7 @@ struct GameListView: View { @Binding var navigationPath: NavigationPath @Environment(\.managedObjectContext) private var viewContext + @Environment(EventLog.self) private var eventLog @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FetchRequest( @@ -486,7 +487,11 @@ struct GameListView: View { private func decline(_ invite: InviteEntity) { invite.status = "declined" - try? viewContext.save() + do { + try viewContext.save() + } catch { + eventLog.note("GameListView: decline invite save failed — \(error)", level: "error") + } } @ViewBuilder