crossmate

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

SyncEngine.swift (85729B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import SwiftUI
      5 
      6 extension EnvironmentValues {
      7     @Entry var syncEngine: SyncEngine? = nil
      8     @Entry var resetDatabase: (() async -> Void)? = nil
      9 }
     10 
     11 extension Notification.Name {
     12     /// Posted by `SyncEngine` after applying fetched record zone changes
     13     /// that affected one or more games. `userInfo["gameIDs"]` is a
     14     /// `Set<UUID>`. `PlayerRoster` observes this to refresh in response
     15     /// to remote name updates and new participants joining.
     16     static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
     17 }
     18 
     19 /// What a Ping record represents. Stored as a string in the CKRecord's
     20 /// `kind` field.
     21 enum PingKind: String, Sendable {
     22     case session
     23     case join
     24     case win
     25     case check
     26     case reveal
     27 }
     28 
     29 /// Granularity of a check/reveal action. Stored as a string in the CKRecord's
     30 /// `scope` field; nil for kinds where it doesn't apply.
     31 enum PingScope: String, Sendable {
     32     case square
     33     case word
     34     case puzzle
     35 }
     36 
     37 struct Ping: Sendable {
     38     let recordName: String
     39     let gameID: UUID
     40     let authorID: String
     41     let playerName: String
     42     let puzzleTitle: String
     43     let kind: PingKind
     44     let scope: PingScope?
     45 }
     46 
     47 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
     48 /// the private database (owned games and shares) and one for the shared
     49 /// database (joined games). Zone creation, subscription setup, change-token
     50 /// management, batching, and retry are all delegated to the framework.
     51 /// This actor's job is to:
     52 ///
     53 /// - Start and persist each engine's state across launches.
     54 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone
     55 ///   changes that CKSyncEngine will batch and send.
     56 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and
     57 ///   replay them onto the `CellEntity` cache.
     58 /// - Notify the main actor so the in-memory `Game` stays current.
     59 actor SyncEngine {
     60     let container: CKContainer
     61     let persistence: PersistenceController
     62 
     63     private var privateEngine: CKSyncEngine?
     64     private var sharedEngine: CKSyncEngine?
     65 
     66     /// In-memory map for Ping records pending send. Pings have no Core Data
     67     /// backing — they're write-once-and-forget — so we stash the minimal data
     68     /// here keyed by record name and look it up in `buildRecord`.
     69     private var pendingPings: [String: PingPayload] = [:]
     70 
     71     private struct PingPayload {
     72         let gameID: UUID
     73         let authorID: String
     74         let playerName: String
     75         let puzzleTitle: String
     76         let eventTimestampMs: Int64
     77         let kind: PingKind
     78         let scope: PingScope?
     79     }
     80 
     81     /// Label for the in-flight fetch — surfaced in traces so the diagnostics
     82     /// log can distinguish push-driven fetches from polls / foreground / etc.
     83     /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler).
     84     private var currentFetchSource: String?
     85     /// One-shot flag — set the first time we observe shared-DB content
     86     /// arriving via a push-triggered fetch. Confirms the silent-push path is
     87     /// actually wired up end-to-end.
     88     private var loggedFirstSharedPushPayload = false
     89 
     90     private var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
     91     private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
     92     private var onAccountChange: (@MainActor @Sendable () async -> Void)?
     93     private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
     94     private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
     95     private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
     96     private var tracer: (@MainActor @Sendable (String) -> Void)?
     97     private var liveQueryCheckpoints: [String: Date] = [:]
     98     private let liveQueryCheckpointOverlap: TimeInterval = 5
     99 
    100     /// Per-scope checkpoint for the background ping fast path. Independent of
    101     /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are
    102     /// per-zone and Moves/Player oriented). Keyed by databaseScope value
    103     /// (0 = private, 1 = shared).
    104     private var pingPushCheckpoints: [Int16: Date] = [:]
    105     private let pingPushCheckpointOverlap: TimeInterval = 30
    106 
    107     func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
    108         tracer = t
    109     }
    110 
    111     func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    112         onRemoteMovesUpdated = cb
    113     }
    114 
    115     func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
    116         onPings = cb
    117     }
    118 
    119     func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
    120         onAccountChange = cb
    121     }
    122 
    123     func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    124         onGameAccessRevoked = cb
    125     }
    126 
    127     func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    128         onGameRemoved = cb
    129     }
    130 
    131     func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
    132         localAuthorIDProvider = cb
    133     }
    134 
    135     init(container: CKContainer, persistence: PersistenceController) {
    136         self.container = container
    137         self.persistence = persistence
    138     }
    139 
    140     // MARK: - Lifecycle
    141 
    142     /// Database subscription IDs. Stable, per-scope, idempotent on re-creation
    143     /// so we can always attempt a save without first listing.
    144     private static let privateSubscriptionID = "crossmate-private-db-subscription"
    145     private static let sharedSubscriptionID = "crossmate-shared-db-subscription"
    146 
    147     /// Creates both `CKSyncEngine` instances, restoring previously-saved state
    148     /// so pending changes and change tokens survive restarts. Call once after
    149     /// wiring callbacks.
    150     func start() {
    151         let bgCtx = persistence.container.newBackgroundContext()
    152 
    153         let privateState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
    154             guard let data = SyncStateEntity.current(in: bgCtx).ckPrivateEngineState else {
    155                 return nil
    156             }
    157             return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
    158         }
    159         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
    160             database: container.privateCloudDatabase,
    161             stateSerialization: privateState,
    162             delegate: self
    163         ))
    164 
    165         let sharedState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
    166             guard let data = SyncStateEntity.current(in: bgCtx).ckSharedEngineState else {
    167                 return nil
    168             }
    169             return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
    170         }
    171         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
    172             database: container.sharedCloudDatabase,
    173             stateSerialization: sharedState,
    174             delegate: self
    175         ))
    176 
    177         // CKSyncEngine's automatic subscription creation is unreliable in
    178         // practice — diagnostics on real devices showed both scopes with zero
    179         // subscriptions even after a healthy initial fetch and push, which
    180         // means CloudKit never fires pushes and the engine silently degrades
    181         // to its periodic poll. Create the database subscriptions ourselves;
    182         // CKDatabase.save is idempotent for an existing subscriptionID.
    183         Task { await ensureDatabaseSubscriptions() }
    184     }
    185 
    186     private func ensureDatabaseSubscriptions() async {
    187         await ensureDatabaseSubscription(
    188             database: container.privateCloudDatabase,
    189             subscriptionID: Self.privateSubscriptionID,
    190             label: "private"
    191         )
    192         await ensureDatabaseSubscription(
    193             database: container.sharedCloudDatabase,
    194             subscriptionID: Self.sharedSubscriptionID,
    195             label: "shared"
    196         )
    197     }
    198 
    199     private func ensureDatabaseSubscription(
    200         database: CKDatabase,
    201         subscriptionID: String,
    202         label: String
    203     ) async {
    204         do {
    205             let existing = try await database.allSubscriptions()
    206             if existing.contains(where: { $0.subscriptionID == subscriptionID }) {
    207                 await trace("\(label) subscription already present (\(subscriptionID))")
    208                 return
    209             }
    210         } catch {
    211             await trace("\(label) allSubscriptions probe failed: \(describe(error)) — attempting save anyway")
    212         }
    213         let subscription = CKDatabaseSubscription(subscriptionID: subscriptionID)
    214         let info = CKSubscription.NotificationInfo()
    215         info.shouldSendContentAvailable = true
    216         subscription.notificationInfo = info
    217         do {
    218             _ = try await database.save(subscription)
    219             await trace("\(label) subscription created (\(subscriptionID))")
    220         } catch {
    221             await trace("\(label) subscription save FAILED: \(describe(error))")
    222         }
    223     }
    224 
    225     // MARK: - Outbound
    226 
    227     /// Registers each game's local-device Moves record as a pending save and
    228     /// kicks the affected engines to send immediately. Called by the
    229     /// `MovesUpdater` sink after the device's `MovesEntity` row has been
    230     /// merged and persisted; the sink already debounces edits, so this fires
    231     /// at most once per typing burst per game. Routes per-game to the correct
    232     /// engine. Going through `sendChanges()` rather than relying on the
    233     /// framework's own scheduler matters because every keystroke targets the
    234     /// same `CKRecord.ID` — the scheduler treats repeated `state.add` calls
    235     /// as the same already-queued intent and may sit on the change for a
    236     /// while before deciding to ship it.
    237     func enqueueMoves(gameIDs: Set<UUID>) {
    238         guard !gameIDs.isEmpty else { return }
    239         var kickPrivate = false
    240         var kickShared = false
    241         let ctx = persistence.container.newBackgroundContext()
    242         ctx.performAndWait {
    243             for gameID in gameIDs {
    244                 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue }
    245                 let isShared = info.scope == 1
    246                 let engine = isShared ? sharedEngine : privateEngine
    247                 guard let engine else { continue }
    248                 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    249                 req.predicate = NSPredicate(
    250                     format: "game.id == %@ AND deviceID == %@",
    251                     gameID as CVarArg,
    252                     RecordSerializer.localDeviceID
    253                 )
    254                 req.fetchLimit = 1
    255                 guard let entity = try? ctx.fetch(req).first,
    256                       let recordName = entity.ckRecordName
    257                 else { continue }
    258                 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
    259                 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    260                 if isShared { kickShared = true } else { kickPrivate = true }
    261             }
    262         }
    263         if kickPrivate, let engine = privateEngine {
    264             Task { try? await engine.sendChanges() }
    265         }
    266         if kickShared, let engine = sharedEngine {
    267             Task { try? await engine.sendChanges() }
    268         }
    269     }
    270 
    271     /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit
    272     /// system fields. Covers crash/relaunch recovery and any save that reached
    273     /// Core Data before CKSyncEngine recorded the pending change.
    274     @discardableResult
    275     func enqueueUnconfirmedMoves() -> Int {
    276         let ctx = persistence.container.newBackgroundContext()
    277         let gameIDs: Set<UUID> = ctx.performAndWait {
    278             let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    279             req.predicate = NSPredicate(
    280                 format: "ckSystemFields == nil AND deviceID == %@",
    281                 RecordSerializer.localDeviceID
    282             )
    283             let entities = (try? ctx.fetch(req)) ?? []
    284             return Set(entities.compactMap { $0.game?.id })
    285         }
    286         enqueueMoves(gameIDs: gameIDs)
    287         return gameIDs.count
    288     }
    289 
    290     /// Registers record deletions as pending sends. Extracts the game UUID
    291     /// from the record name and routes to the correct engine.
    292     /// Registers the game's CloudKit zone for deletion. Each game owns its
    293     /// own zone, so this removes all remote records for the puzzle, including
    294     /// moves, player records, session pings, and share metadata.
    295     func enqueueDeleteGame(_ deletion: GameCloudDeletion) {
    296         let zoneID = CKRecordZone.ID(
    297             zoneName: deletion.ckZoneName,
    298             ownerName: deletion.ckZoneOwnerName
    299         )
    300         let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine
    301         guard let engine else { return }
    302         engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
    303         Task { try? await engine.sendChanges() }
    304     }
    305 
    306     /// Registers a Ping record as a pending send. Pings cover session-start,
    307     /// join, win, check, and reveal events; sender-only state — the payload is
    308     /// stashed in `pendingPings` and only used to build the outgoing
    309     /// `CKRecord`; nothing is persisted.
    310     func enqueuePing(
    311         kind: PingKind,
    312         scope: PingScope?,
    313         gameID: UUID,
    314         authorID: String,
    315         playerName: String
    316     ) {
    317         let ctx = persistence.container.newBackgroundContext()
    318         let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait {
    319             guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil }
    320             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    321             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    322             req.fetchLimit = 1
    323             let entity = try? ctx.fetch(req).first
    324             let title = Self.notificationTitle(for: entity)
    325             return (info, title)
    326         }
    327         guard let zoneAndTitle else { return }
    328         let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine
    329         guard let engine else { return }
    330         let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
    331         let recordName = RecordSerializer.recordName(
    332             forPingInGame: gameID,
    333             authorID: authorID,
    334             eventTimestampMs: eventTimestampMs
    335         )
    336         pendingPings[recordName] = PingPayload(
    337             gameID: gameID,
    338             authorID: authorID,
    339             playerName: playerName,
    340             puzzleTitle: zoneAndTitle.title,
    341             eventTimestampMs: eventTimestampMs,
    342             kind: kind,
    343             scope: scope
    344         )
    345         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
    346         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    347         Task { try? await engine.sendChanges() }
    348     }
    349 
    350     /// Deletes transient Ping records for a completed owned game while keeping
    351     /// every `.win` ping. Participants see the owner's zone through the share,
    352     /// so the owner-side deletion removes the records from the cooperative
    353     /// game without risking the final win notification.
    354     func deleteNonWinPings(forCompletedGame gameID: UUID) async throws {
    355         let ctx = persistence.container.newBackgroundContext()
    356         guard let info = zoneInfo(forGameID: gameID, in: ctx),
    357               info.scope == 0
    358         else { return }
    359 
    360         removePendingNonWinPings(for: gameID, zoneID: info.zoneID)
    361 
    362         let records = try await queryLiveRecords(
    363             type: "Ping",
    364             database: container.privateCloudDatabase,
    365             zoneID: info.zoneID,
    366             since: nil,
    367             desiredKeys: ["kind"]
    368         )
    369         let recordIDsToDelete = records.compactMap { record -> CKRecord.ID? in
    370             guard (record["kind"] as? String) != PingKind.win.rawValue else { return nil }
    371             return record.recordID
    372         }
    373         try await deleteRecords(withIDs: recordIDsToDelete, in: container.privateCloudDatabase)
    374         if !recordIDsToDelete.isEmpty {
    375             await trace("ping cleanup: deleted \(recordIDsToDelete.count) non-win ping(s) for \(gameID.uuidString)")
    376         }
    377     }
    378 
    379     private func removePendingNonWinPings(for gameID: UUID, zoneID: CKRecordZone.ID) {
    380         let namesToRemove = Set(pendingPings.compactMap { name, payload in
    381             payload.gameID == gameID && payload.kind != .win ? name : nil
    382         })
    383         guard !namesToRemove.isEmpty else { return }
    384 
    385         for name in namesToRemove {
    386             pendingPings.removeValue(forKey: name)
    387         }
    388         guard let privateEngine else { return }
    389         let changesToRemove = privateEngine.state.pendingRecordZoneChanges.filter { change in
    390             switch change {
    391             case .saveRecord(let id):
    392                 return id.zoneID == zoneID && namesToRemove.contains(id.recordName)
    393             case .deleteRecord:
    394                 return false
    395             @unknown default:
    396                 return false
    397             }
    398         }
    399         if !changesToRemove.isEmpty {
    400             privateEngine.state.remove(pendingRecordZoneChanges: changesToRemove)
    401         }
    402     }
    403 
    404     private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {
    405         guard let entity else { return "" }
    406         return PuzzleNotificationText.title(
    407             entity.title ?? "",
    408             publisher: entity.cachedPublisher,
    409             date: entity.cachedPuzzleDate
    410         )
    411     }
    412 
    413     /// Registers a Player record as a pending send. Used by `PlayerNamePublisher`
    414     /// when the local user renames; one record per (game, authorID), so
    415     /// participants only ever write their own slot.
    416     func enqueuePlayerRecord(gameID: UUID, authorID: String) {
    417         let ctx = persistence.container.newBackgroundContext()
    418         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
    419         let engine = info.scope == 1 ? sharedEngine : privateEngine
    420         guard let engine else { return }
    421         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    422         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
    423         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    424         Task { try? await engine.sendChanges() }
    425     }
    426 
    427     /// Registers a Game record as a pending send and ensures its zone is
    428     /// created in CloudKit first. Called when a new game is created locally.
    429     func enqueueGame(ckRecordName: String) {
    430         guard let gameID = gameID(fromRecordName: ckRecordName) else { return }
    431         let ctx = persistence.container.newBackgroundContext()
    432         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
    433         let engine = info.scope == 1 ? sharedEngine : privateEngine
    434         guard let engine else { return }
    435         // Save the zone before the game record so it exists when records arrive.
    436         engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))])
    437         let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
    438         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    439         Task { try? await engine.sendChanges() }
    440     }
    441 
    442     // MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
    443 
    444     func fetchChanges(source: String = "manual") async throws {
    445         currentFetchSource = source
    446         defer { currentFetchSource = nil }
    447         async let p: Void = privateEngine?.fetchChanges() ?? ()
    448         async let s: Void = sharedEngine?.fetchChanges() ?? ()
    449         _ = try await (p, s)
    450     }
    451 
    452     /// Push-only fallback path for the currently open game. On device,
    453     /// CKSyncEngine.fetchChanges() can return successfully from a silent-push
    454     /// wake without delivering database or record-zone events until a later
    455     /// foreground fetch. This direct pull is deliberately narrow: it refreshes
    456     /// the active game's Game record and recently-modified Moves/Player
    457     /// records in that game's zone, then applies them through the same
    458     /// idempotent merge path used by CKSyncEngine events. Event-like records
    459     /// such as Ping are intentionally ignored because live play only needs the
    460     /// current collaboration state.
    461     @discardableResult
    462     func fetchPushChangesDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool {
    463         let database: CKDatabase
    464         let scopeValue: Int16
    465         let label: String
    466         switch scope {
    467         case .private:
    468             database = container.privateCloudDatabase
    469             scopeValue = 0
    470             label = "private"
    471         case .shared:
    472             database = container.sharedCloudDatabase
    473             scopeValue = 1
    474             label = "shared"
    475         case .public:
    476             return false
    477         @unknown default:
    478             return false
    479         }
    480 
    481         let ctx = persistence.container.newBackgroundContext()
    482         guard let info = zoneInfo(forGameID: gameID, in: ctx),
    483               info.scope == scopeValue
    484         else {
    485             await trace("\(label) live query skipped: no active game in scope")
    486             return false
    487         }
    488 
    489         let checkpointKey = "\(scopeValue):\(gameID.uuidString)"
    490         let since = liveQueryCheckpoints[checkpointKey]?
    491             .addingTimeInterval(-liveQueryCheckpointOverlap)
    492 
    493         let gameRecordID = CKRecord.ID(
    494             recordName: RecordSerializer.recordName(forGameID: gameID),
    495             zoneID: info.zoneID
    496         )
    497         // The Game fetch and the Moves/Player queries are independent CK
    498         // round-trips. Fire them in parallel so total latency is bounded by
    499         // the slowest of the three rather than their sum.
    500         async let gameResultsTask = database.records(
    501             for: [gameRecordID],
    502             desiredKeys: ["title", "completedAt", "shareRecordName"]
    503         )
    504         async let movesTask = queryLiveRecords(
    505             type: "Moves",
    506             database: database,
    507             zoneID: info.zoneID,
    508             since: since,
    509             desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
    510         )
    511         async let playersTask = queryLiveRecords(
    512             type: "Player",
    513             database: database,
    514             zoneID: info.zoneID,
    515             since: since,
    516             desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
    517         )
    518         let gameResults = try await gameResultsTask
    519         let moves = try await movesTask
    520         let players = try await playersTask
    521 
    522         var records: [CKRecord] = []
    523         let fetchedGameRecord: Bool
    524         if case .success(let record)? = gameResults[gameRecordID] {
    525             records.append(record)
    526             fetchedGameRecord = true
    527         } else {
    528             fetchedGameRecord = false
    529         }
    530         records.append(contentsOf: moves)
    531         records.append(contentsOf: players)
    532 
    533         await applyDirectRecordZoneChanges(
    534             records: records,
    535             deletions: [],
    536             scopeValue: scopeValue
    537         )
    538 
    539         let latestModification = records.compactMap(\.modificationDate).max()
    540         if let latestModification {
    541             liveQueryCheckpoints[checkpointKey] = latestModification
    542         }
    543 
    544         await trace(
    545             "\(label) live query fetch \(gameID.uuidString.prefix(8)): " +
    546             "game=\(fetchedGameRecord ? 1 : 0), " +
    547             "moves=\(moves.count), players=\(players.count)"
    548         )
    549         return true
    550     }
    551 
    552     /// Background-wake fast path for surfacing collaborator notifications.
    553     /// Queries every known zone in the given scope for Ping records modified
    554     /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses
    555     /// `CKSyncEngine.fetchChanges()` because that path can return without
    556     /// delivering events from a silent-push wake (same Apple quirk that
    557     /// motivated `fetchPushChangesDirect`). Moves / Player / Game records are
    558     /// deliberately left for the engine-driven or foreground fetch.
    559     @discardableResult
    560     func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int {
    561         let database: CKDatabase
    562         let scopeValue: Int16
    563         let label: String
    564         switch scope {
    565         case .private:
    566             database = container.privateCloudDatabase
    567             scopeValue = 0
    568             label = "private"
    569         case .shared:
    570             database = container.sharedCloudDatabase
    571             scopeValue = 1
    572             label = "shared"
    573         case .public:
    574             return 0
    575         @unknown default:
    576             return 0
    577         }
    578 
    579         let ctx = persistence.container.newBackgroundContext()
    580         let zones = knownZones(forScope: scopeValue, in: ctx)
    581         guard !zones.isEmpty else {
    582             await trace("\(label) ping fast-path: no known zones")
    583             return 0
    584         }
    585 
    586         let scopeCheckpoint = pingPushCheckpoints[scopeValue]?
    587             .addingTimeInterval(-pingPushCheckpointOverlap)
    588 
    589         // Fan the per-zone Ping queries out concurrently. The actor's await
    590         // points release isolation between round-trips, so the per-zone CK
    591         // requests overlap; a serial N-zone scan becomes a single parallel
    592         // batch. Per-zone errors are caught and traced so one transient
    593         // failure doesn't suppress notifications from healthy zones.
    594         let perZoneRecords = await withTaskGroup(of: [CKRecord].self) { group in
    595             for (zoneID, createdAt) in zones {
    596                 // Scope checkpoint (if present) wins — it's forward-moving
    597                 // across all zones. On first run for a given scope we fall
    598                 // back to the game's createdAt floor so the ping that
    599                 // triggered this wake is still in range, but pings older
    600                 // than the device's first knowledge of the game are not.
    601                 let since = scopeCheckpoint
    602                     ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap)
    603                 group.addTask { [weak self] in
    604                     guard let self else { return [] }
    605                     do {
    606                         return try await self.queryLiveRecords(
    607                             type: "Ping",
    608                             database: database,
    609                             zoneID: zoneID,
    610                             since: since,
    611                             desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"]
    612                         )
    613                     } catch {
    614                         await self.trace(
    615                             "\(label) ping fast-path: zone \(zoneID.zoneName) failed: " +
    616                             "\(error.localizedDescription)"
    617                         )
    618                         return []
    619                     }
    620                 }
    621             }
    622             var all: [[CKRecord]] = []
    623             for await batch in group {
    624                 all.append(batch)
    625             }
    626             return all
    627         }
    628         let collected: [CKRecord] = perZoneRecords.flatMap { $0 }
    629 
    630         let pings = collected.compactMap(Self.parsePingRecord)
    631         if let latest = collected.compactMap(\.modificationDate).max() {
    632             pingPushCheckpoints[scopeValue] = latest
    633         }
    634         await trace(
    635             "\(label) ping fast-path: zones=\(zones.count), pings=\(pings.count)"
    636         )
    637 
    638         if !pings.isEmpty, let onPings {
    639             await onPings(pings)
    640         }
    641         return pings.count
    642     }
    643 
    644     /// Discovers games whose zones the device has never seen and pulls their
    645     /// Game / Moves / Player records directly, bypassing CKSyncEngine.
    646     ///
    647     /// CKSyncEngine is supposed to deliver database-scope change events that
    648     /// announce new zones, but on a silent-push wake those events can be
    649     /// withheld until the next foreground (the same quirk that motivated
    650     /// `fetchPushChangesDirect` and `fetchPushPingsDirect`). Without zone
    651     /// discovery, a game created on one device only appears on a second
    652     /// device after CKSyncEngine eventually catches up — which can be a long
    653     /// time if the second device only ever opens the app briefly.
    654     ///
    655     /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against
    656     /// `knownZones`, and pulls every record type we care about for each new
    657     /// zone. The pull is unbounded in time because, by definition, the
    658     /// device has no checkpoint for a zone it hasn't seen.
    659     ///
    660     /// Returns the number of newly-discovered zones.
    661     @discardableResult
    662     func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int {
    663         let database: CKDatabase
    664         let scopeValue: Int16
    665         let label: String
    666         switch scope {
    667         case .private:
    668             database = container.privateCloudDatabase
    669             scopeValue = 0
    670             label = "private"
    671         case .shared:
    672             database = container.sharedCloudDatabase
    673             scopeValue = 1
    674             label = "shared"
    675         case .public:
    676             return 0
    677         @unknown default:
    678             return 0
    679         }
    680 
    681         let serverZones = try await database.allRecordZones()
    682         let ctx = persistence.container.newBackgroundContext()
    683         let known = knownZones(forScope: scopeValue, in: ctx)
    684         let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" })
    685 
    686         let candidates: [CKRecordZone.ID] = serverZones
    687             .map(\.zoneID)
    688             .filter { id in
    689                 id != CKRecordZone.ID.default &&
    690                 !knownKeys.contains("\(id.ownerName)|\(id.zoneName)")
    691             }
    692 
    693         guard !candidates.isEmpty else {
    694             await trace(
    695                 "\(label) zone discovery: nothing new " +
    696                 "(server=\(serverZones.count), known=\(known.count))"
    697             )
    698             return 0
    699         }
    700 
    701         // Two layers of concurrency. Outer: fan the per-zone work out
    702         // through a TaskGroup so N candidate zones don't serialize. Inner:
    703         // fire Game / Moves / Player against each zone with `async let` so
    704         // a single zone's three round-trips also overlap. The Game query
    705         // gates whether the zone hosts a Crossmate puzzle, but Moves and
    706         // Player against a non-puzzle zone simply return empty, so always
    707         // pulling all three in parallel and discarding when Game is empty
    708         // is cheaper than waiting on Game first. Per-zone errors are
    709         // caught and traced so one bad zone doesn't abort the rest.
    710         struct PerZoneResult: Sendable {
    711             let records: [CKRecord]
    712             let hasGame: Bool
    713         }
    714         let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in
    715             for zoneID in candidates {
    716                 group.addTask { [weak self] in
    717                     guard let self else {
    718                         return PerZoneResult(records: [], hasGame: false)
    719                     }
    720                     do {
    721                         async let games = try await self.queryLiveRecords(
    722                             type: "Game",
    723                             database: database,
    724                             zoneID: zoneID,
    725                             since: nil,
    726                             desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
    727                         )
    728                         async let moves = try await self.queryLiveRecords(
    729                             type: "Moves",
    730                             database: database,
    731                             zoneID: zoneID,
    732                             since: nil,
    733                             desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
    734                         )
    735                         async let players = try await self.queryLiveRecords(
    736                             type: "Player",
    737                             database: database,
    738                             zoneID: zoneID,
    739                             since: nil,
    740                             desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
    741                         )
    742                         let (g, m, p) = try await (games, moves, players)
    743                         guard !g.isEmpty else {
    744                             return PerZoneResult(records: [], hasGame: false)
    745                         }
    746                         return PerZoneResult(records: g + m + p, hasGame: true)
    747                     } catch {
    748                         await self.trace(
    749                             "\(label) zone discovery: zone \(zoneID.zoneName) failed: " +
    750                             "\(error.localizedDescription)"
    751                         )
    752                         return PerZoneResult(records: [], hasGame: false)
    753                     }
    754                 }
    755             }
    756             var all: [PerZoneResult] = []
    757             for await result in group {
    758                 all.append(result)
    759             }
    760             return all
    761         }
    762         let collected: [CKRecord] = perZoneResults.flatMap(\.records)
    763         let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 }
    764 
    765         await applyDirectRecordZoneChanges(
    766             records: collected,
    767             deletions: [],
    768             scopeValue: scopeValue
    769         )
    770 
    771         await trace(
    772             "\(label) zone discovery: candidates=\(candidates.count), " +
    773             "withGame=\(zonesWithGame), records=\(collected.count)"
    774         )
    775         return zonesWithGame
    776     }
    777 
    778     /// Pulls incremental updates for every game the device already knows
    779     /// about in the given scope, bypassing CKSyncEngine. Pairs with
    780     /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves
    781     /// of "what might have changed elsewhere": new zones *and* updates to
    782     /// existing ones. Each game is dispatched to the existing
    783     /// `fetchPushChangesDirect`, which uses the `liveQueryCheckpoints`
    784     /// cursor so we only pull Moves/Player records newer than the last
    785     /// direct fetch. Per-game errors are caught and traced so one bad zone
    786     /// doesn't abort the rest.
    787     ///
    788     /// Returns the number of games for which the direct fetch reported
    789     /// records were applied.
    790     @discardableResult
    791     func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int {
    792         let scopeValue: Int16
    793         let label: String
    794         switch scope {
    795         case .private:
    796             scopeValue = 0
    797             label = "private"
    798         case .shared:
    799             scopeValue = 1
    800             label = "shared"
    801         case .public:
    802             return 0
    803         @unknown default:
    804             return 0
    805         }
    806 
    807         let ctx = persistence.container.newBackgroundContext()
    808         let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx)
    809         guard !gameIDs.isEmpty else {
    810             await trace("\(label) known-zone refresh: no known games")
    811             return 0
    812         }
    813 
    814         // Fan the per-game fetches out concurrently. Each fetchPushChangesDirect
    815         // call hits a different zone with a different checkpoint key, so they
    816         // don't race on shared state. The actor still serializes access to
    817         // liveQueryCheckpoints at non-await points, but the actual CK round-
    818         // trips overlap, turning a serial 1s-per-game wait into a single
    819         // parallel batch.
    820         let handled = await withTaskGroup(of: Bool.self) { group in
    821             for gameID in gameIDs {
    822                 group.addTask { [weak self] in
    823                     guard let self else { return false }
    824                     do {
    825                         return try await self.fetchPushChangesDirect(
    826                             scope: scope,
    827                             gameID: gameID
    828                         )
    829                     } catch {
    830                         await self.trace(
    831                             "\(label) known-zone refresh: game " +
    832                             "\(gameID.uuidString.prefix(8)) failed: " +
    833                             "\(error.localizedDescription)"
    834                         )
    835                         return false
    836                     }
    837                 }
    838             }
    839             var count = 0
    840             for await result in group where result {
    841                 count += 1
    842             }
    843             return count
    844         }
    845         await trace(
    846             "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)"
    847         )
    848         return handled
    849     }
    850 
    851     private func queryLiveRecords(
    852         type: CKRecord.RecordType,
    853         database: CKDatabase,
    854         zoneID: CKRecordZone.ID,
    855         since: Date?,
    856         desiredKeys: [CKRecord.FieldKey]
    857     ) async throws -> [CKRecord] {
    858         let since = since ?? Date(timeIntervalSince1970: 0)
    859         let predicate = NSPredicate(format: "modificationDate > %@", since as NSDate)
    860         let query = CKQuery(recordType: type, predicate: predicate)
    861 
    862         var records: [CKRecord] = []
    863         var result = try await database.records(
    864             matching: query,
    865             inZoneWith: zoneID,
    866             desiredKeys: desiredKeys,
    867             resultsLimit: CKQueryOperation.maximumResults
    868         )
    869         records.append(contentsOf: result.matchResults.compactMap { _, recordResult in
    870             try? recordResult.get()
    871         })
    872 
    873         while let cursor = result.queryCursor {
    874             result = try await database.records(
    875                 continuingMatchFrom: cursor,
    876                 desiredKeys: desiredKeys,
    877                 resultsLimit: CKQueryOperation.maximumResults
    878             )
    879             records.append(contentsOf: result.matchResults.compactMap { _, recordResult in
    880                 try? recordResult.get()
    881             })
    882         }
    883         return records
    884     }
    885 
    886     private func deleteRecords(
    887         withIDs recordIDs: [CKRecord.ID],
    888         in database: CKDatabase
    889     ) async throws {
    890         guard !recordIDs.isEmpty else { return }
    891         let batchSize = 200
    892         var index = recordIDs.startIndex
    893         while index < recordIDs.endIndex {
    894             let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex)
    895                 ?? recordIDs.endIndex
    896             let batch = Array(recordIDs[index..<end])
    897             try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    898                 let op = CKModifyRecordsOperation(
    899                     recordsToSave: nil,
    900                     recordIDsToDelete: batch
    901                 )
    902                 op.qualityOfService = .utility
    903                 op.modifyRecordsResultBlock = { result in
    904                     cont.resume(with: result)
    905                 }
    906                 database.add(op)
    907             }
    908             index = end
    909         }
    910     }
    911 
    912     private func applyDirectRecordZoneChanges(
    913         records: [CKRecord],
    914         deletions: [(CKRecord.ID, CKRecord.RecordType)],
    915         scopeValue: Int16
    916     ) async {
    917         guard !records.isEmpty || !deletions.isEmpty else { return }
    918         let ctx = persistence.container.newBackgroundContext()
    919         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    920         let localAuthorID = await currentLocalAuthorID()
    921         let (movesUpdatedGameIDs, affectedGameIDs): (Set<UUID>, Set<UUID>) = ctx.performAndWait {
    922             var movesUpdated = Set<UUID>()
    923             var affected = Set<UUID>()
    924             for record in records {
    925                 switch record.recordType {
    926                 case "Game":
    927                     let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scopeValue)
    928                     if let id = entity.id { affected.insert(id) }
    929                 case "Moves":
    930                     if let value = RecordSerializer.parseMovesRecord(record) {
    931                         RecordSerializer.applyMovesRecord(
    932                             record,
    933                             value: value,
    934                             to: ctx,
    935                             localAuthorID: localAuthorID
    936                         )
    937                         movesUpdated.insert(value.gameID)
    938                         affected.insert(value.gameID)
    939                     }
    940                 case "Player":
    941                     if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
    942                         self.applyPlayerRecord(record, in: ctx)
    943                         affected.insert(gameID)
    944                     }
    945                 default:
    946                     break
    947                 }
    948             }
    949             for deletion in deletions {
    950                 self.applyDeletion(
    951                     recordID: deletion.0,
    952                     recordType: deletion.1,
    953                     in: ctx
    954                 )
    955                 if let id = self.gameID(fromRecordName: deletion.0.recordName) {
    956                     affected.insert(id)
    957                 }
    958             }
    959             for gameID in movesUpdated {
    960                 self.replayCellCache(for: gameID, in: ctx)
    961             }
    962             if ctx.hasChanges {
    963                 do {
    964                     try ctx.save()
    965                 } catch {
    966                     let nsError = error as NSError
    967                     print(
    968                         "SyncEngine: direct-push ctx.save failed " +
    969                         "— domain=\(nsError.domain) code=\(nsError.code) " +
    970                         "\(nsError.localizedDescription)"
    971                     )
    972                 }
    973             }
    974             return (movesUpdated, affected)
    975         }
    976 
    977         if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
    978             await onRemoteMovesUpdated(movesUpdatedGameIDs)
    979         }
    980         if !affectedGameIDs.isEmpty {
    981             NotificationCenter.default.post(
    982                 name: .playerRosterShouldRefresh,
    983                 object: nil,
    984                 userInfo: ["gameIDs": affectedGameIDs]
    985             )
    986         }
    987     }
    988 
    989     func pushChanges() async throws {
    990         async let p: Void = privateEngine?.sendChanges() ?? ()
    991         async let s: Void = sharedEngine?.sendChanges() ?? ()
    992         _ = try await (p, s)
    993     }
    994 
    995     // MARK: - Diagnostics
    996 
    997     struct DiagnosticSnapshot: Sendable {
    998         let accountStatus: CKAccountStatus
    999         let engineRunning: Bool
   1000         let pendingChangesCount: Int
   1001         let privatePendingCount: Int
   1002         let sharedPendingCount: Int
   1003     }
   1004 
   1005     /// Record names of pending `.saveRecord` changes queued on the given
   1006     /// scope's engine. Used by tests to verify that outbound enqueues route
   1007     /// to the correct database.
   1008     func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] {
   1009         let engine = scope == .shared ? sharedEngine : privateEngine
   1010         guard let engine else { return [] }
   1011         return engine.state.pendingRecordZoneChanges.compactMap {
   1012             if case .saveRecord(let id) = $0 { return id.recordName }
   1013             return nil
   1014         }
   1015     }
   1016 
   1017     /// Zone names queued for deletion on the given scope's engine. Used by
   1018     /// tests to verify delete routing after the local GameEntity is gone.
   1019     func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] {
   1020         let engine = scope == .shared ? sharedEngine : privateEngine
   1021         guard let engine else { return [] }
   1022         return engine.state.pendingDatabaseChanges.compactMap {
   1023             if case .deleteZone(let id) = $0 { return id.zoneName }
   1024             return nil
   1025         }
   1026     }
   1027 
   1028     func diagnosticSnapshot() async -> DiagnosticSnapshot {
   1029         let status: CKAccountStatus
   1030         do { status = try await container.accountStatus() }
   1031         catch { status = .couldNotDetermine }
   1032         let running = privateEngine != nil
   1033         let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
   1034         let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
   1035         return DiagnosticSnapshot(
   1036             accountStatus: status,
   1037             engineRunning: running,
   1038             pendingChangesCount: privateCount + sharedCount,
   1039             privatePendingCount: privateCount,
   1040             sharedPendingCount: sharedCount
   1041         )
   1042     }
   1043 
   1044     /// Runs a series of lightweight CloudKit probes and returns human-readable
   1045     /// (name, result) pairs for display in the diagnostics view.
   1046     func probeContainer() async -> [(name: String, result: String)] {
   1047         var results: [(String, String)] = []
   1048         results.append(("containerIdentifier", container.containerIdentifier ?? "nil"))
   1049         do {
   1050             let s = try await container.accountStatus()
   1051             results.append(("accountStatus", describeStatus(s)))
   1052         } catch {
   1053             results.append(("accountStatus", describe(error)))
   1054         }
   1055         do {
   1056             let id = try await container.userRecordID()
   1057             results.append(("userRecordID", id.recordName))
   1058         } catch {
   1059             results.append(("userRecordID", describe(error)))
   1060         }
   1061         do {
   1062             let zones = try await container.privateCloudDatabase.allRecordZones()
   1063             let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
   1064             results.append(("privateZones", "\(zones.count) zone(s): [\(names)]"))
   1065         } catch {
   1066             results.append(("privateZones", describe(error)))
   1067         }
   1068         do {
   1069             let zones = try await container.sharedCloudDatabase.allRecordZones()
   1070             let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
   1071             results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]"))
   1072         } catch {
   1073             results.append(("sharedZones", describe(error)))
   1074         }
   1075         // CKSyncEngine creates a CKDatabaseSubscription per scope on first
   1076         // start. If subscription creation silently failed, no push will ever
   1077         // fire for that scope — surface what's actually present so a missing
   1078         // entry is visible from the diagnostics view rather than diagnosed
   1079         // by elimination.
   1080         results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs"))
   1081         results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs"))
   1082         return results
   1083     }
   1084 
   1085     private func probeSubscriptions(
   1086         database: CKDatabase,
   1087         label: String
   1088     ) async -> (String, String) {
   1089         do {
   1090             let subs = try await database.allSubscriptions()
   1091             if subs.isEmpty {
   1092                 return (label, "0 subscriptions — pushes will not fire")
   1093             }
   1094             let descriptions = subs.map { sub -> String in
   1095                 let kind: String
   1096                 switch sub {
   1097                 case is CKDatabaseSubscription: kind = "database"
   1098                 case is CKQuerySubscription: kind = "query"
   1099                 case is CKRecordZoneSubscription: kind = "zone"
   1100                 default: kind = "other(\(type(of: sub)))"
   1101                 }
   1102                 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only"
   1103                 return "\(kind):\(sub.subscriptionID)[\(silent)]"
   1104             }
   1105             return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]")
   1106         } catch {
   1107             return (label, describe(error))
   1108         }
   1109     }
   1110 
   1111     /// Fetches a single record by ID for the in-app record editor. Bypasses
   1112     /// CKSyncEngine's tracked changes — caller is responsible for triggering a
   1113     /// reconciling fetch if the record corresponds to a tracked local entity.
   1114     func fetchRecordForEdit(
   1115         scope: CKDatabase.Scope,
   1116         recordID: CKRecord.ID
   1117     ) async throws -> CKRecord {
   1118         let database = scope == .shared
   1119             ? container.sharedCloudDatabase
   1120             : container.privateCloudDatabase
   1121         return try await database.record(for: recordID)
   1122     }
   1123 
   1124     /// Saves a record edited in the in-app record editor and runs a follow-up
   1125     /// `fetchChanges` so any locally-tracked entity picks up the new server
   1126     /// change tag via CKSyncEngine rather than going stale.
   1127     func saveRecordForEdit(
   1128         scope: CKDatabase.Scope,
   1129         record: CKRecord
   1130     ) async throws -> CKRecord {
   1131         let database = scope == .shared
   1132             ? container.sharedCloudDatabase
   1133             : container.privateCloudDatabase
   1134         let saved = try await database.save(record)
   1135         try? await fetchChanges(source: "record-editor")
   1136         return saved
   1137     }
   1138 
   1139     /// Clears the saved state for both engines and replaces the in-memory
   1140     /// engine instances so subsequent fetches walk every zone from scratch.
   1141     /// Clearing the persisted state alone is ineffective: the running engines
   1142     /// hold their tokens in memory and the next `stateUpdate` event saves
   1143     /// those tokens back, so the wipe is undone before the user can act on it.
   1144     /// Pending records already in CloudKit are unaffected. Locally-unconfirmed
   1145     /// moves are re-enqueued so the new engines push them on the next cycle.
   1146     func resetSyncState() async {
   1147         let ctx = persistence.container.newBackgroundContext()
   1148         ctx.performAndWait {
   1149             let entity = SyncStateEntity.current(in: ctx)
   1150             entity.ckPrivateEngineState = nil
   1151             entity.ckSharedEngineState = nil
   1152             try? ctx.save()
   1153         }
   1154         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1155             database: container.privateCloudDatabase,
   1156             stateSerialization: nil,
   1157             delegate: self
   1158         ))
   1159         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1160             database: container.sharedCloudDatabase,
   1161             stateSerialization: nil,
   1162             delegate: self
   1163         ))
   1164         pendingPings = [:]
   1165         pingPushCheckpoints = [:]
   1166         liveQueryCheckpoints = [:]
   1167         loggedFirstSharedPushPayload = false
   1168         _ = enqueueUnconfirmedMoves()
   1169     }
   1170 
   1171     // MARK: - Private helpers
   1172 
   1173     private func currentLocalAuthorID() async -> String? {
   1174         guard let localAuthorIDProvider else { return nil }
   1175         return await MainActor.run {
   1176             localAuthorIDProvider()
   1177         }
   1178     }
   1179 
   1180     private struct ZoneInfo {
   1181         let scope: Int16
   1182         let zoneID: CKRecordZone.ID
   1183     }
   1184 
   1185     /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if
   1186     /// the entity can't be found. Not `async` — uses `performAndWait` so it
   1187     /// can be called from non-async actor context.
   1188     private nonisolated func zoneInfo(
   1189         forGameID gameID: UUID,
   1190         in ctx: NSManagedObjectContext
   1191     ) -> ZoneInfo? {
   1192         ctx.performAndWait {
   1193             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1194             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1195             req.fetchLimit = 1
   1196             guard let entity = try? ctx.fetch(req).first else { return nil }
   1197             let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
   1198             let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
   1199             return ZoneInfo(
   1200                 scope: entity.databaseScope,
   1201                 zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
   1202             )
   1203         }
   1204     }
   1205 
   1206     /// Game UUIDs for every locally-known *in-progress* game in the given
   1207     /// database scope. Used by the known-zone refresh path so each game can
   1208     /// be routed through `fetchPushChangesDirect`. Games with a non-nil
   1209     /// `completedAt` are excluded: once a game is completed no further moves
   1210     /// or player updates can arrive, so refreshing those zones is wasted
   1211     /// round-trips.
   1212     private nonisolated func knownGameIDs(
   1213         forScope scope: Int16,
   1214         in ctx: NSManagedObjectContext
   1215     ) -> [UUID] {
   1216         ctx.performAndWait {
   1217             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1218             req.predicate = NSPredicate(
   1219                 format: "databaseScope == %d AND completedAt == nil",
   1220                 scope
   1221             )
   1222             guard let entities = try? ctx.fetch(req) else { return [] }
   1223             return entities.compactMap(\.id)
   1224         }
   1225     }
   1226 
   1227     /// Enumerates every known game zone for the given database scope, paired
   1228     /// with the `createdAt` of the corresponding GameEntity. The createdAt
   1229     /// timestamp is used as the per-zone floor for the ping fast path: pings
   1230     /// older than the moment this device first knew about the game can't be
   1231     /// of interest (for shared games, they pre-date our join; for owned
   1232     /// games, they pre-date the game's existence).
   1233     private nonisolated func knownZones(
   1234         forScope scope: Int16,
   1235         in ctx: NSManagedObjectContext
   1236     ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] {
   1237         ctx.performAndWait {
   1238             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1239             req.predicate = NSPredicate(format: "databaseScope == %d", scope)
   1240             guard let entities = try? ctx.fetch(req) else { return [] }
   1241             var seen = Set<String>()
   1242             var result: [(CKRecordZone.ID, Date)] = []
   1243             for entity in entities {
   1244                 guard let gameID = entity.id else { continue }
   1245                 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
   1246                 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
   1247                 let key = "\(ownerName)|\(zoneName)"
   1248                 guard seen.insert(key).inserted else { continue }
   1249                 let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0)
   1250                 result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt))
   1251             }
   1252             return result
   1253         }
   1254     }
   1255 
   1256     /// Extracts the game UUID from any of our record name formats:
   1257     /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`.
   1258     private nonisolated func gameID(fromRecordName name: String) -> UUID? {
   1259         if name.hasPrefix("game-") {
   1260             return UUID(uuidString: String(name.dropFirst("game-".count)))
   1261         }
   1262         let prefix: String
   1263         if name.hasPrefix("moves-") { prefix = "moves-" }
   1264         else if name.hasPrefix("player-") { prefix = "player-" }
   1265         else if name.hasPrefix("ping-") { prefix = "ping-" }
   1266         else { return nil }
   1267         let rest = name.dropFirst(prefix.count)
   1268         return UUID(uuidString: String(rest.prefix(36)))
   1269     }
   1270 
   1271     private func trace(_ message: String) async {
   1272         guard let tracer else { return }
   1273         await tracer(message)
   1274     }
   1275 
   1276     private func saveEngineState(
   1277         _ serialization: CKSyncEngine.State.Serialization,
   1278         isPrivate: Bool
   1279     ) {
   1280         let ctx = persistence.container.newBackgroundContext()
   1281         ctx.performAndWait {
   1282             guard let data = try? JSONEncoder().encode(serialization) else { return }
   1283             let entity = SyncStateEntity.current(in: ctx)
   1284             if isPrivate {
   1285                 entity.ckPrivateEngineState = data
   1286             } else {
   1287                 entity.ckSharedEngineState = data
   1288             }
   1289             try? ctx.save()
   1290         }
   1291     }
   1292 
   1293     /// Builds the `CKRecord` for a pending change. Uses the zone ID already
   1294     /// embedded in the `recordID` — set correctly at enqueue time.
   1295     /// `pings` is a snapshot taken from the actor before this is invoked,
   1296     /// since the framework calls back synchronously off-actor.
   1297     private nonisolated func buildRecord(
   1298         for recordID: CKRecord.ID,
   1299         pings: [String: PingPayload]
   1300     ) -> CKRecord? {
   1301         let name = recordID.recordName
   1302         let zoneID = recordID.zoneID
   1303         if name.hasPrefix("ping-") {
   1304             guard let payload = pings[name] else { return nil }
   1305             return RecordSerializer.pingRecord(
   1306                 gameID: payload.gameID,
   1307                 authorID: payload.authorID,
   1308                 playerName: payload.playerName,
   1309                 puzzleTitle: payload.puzzleTitle,
   1310                 eventTimestampMs: payload.eventTimestampMs,
   1311                 kind: payload.kind,
   1312                 scope: payload.scope,
   1313                 zone: zoneID
   1314             )
   1315         }
   1316         let ctx = persistence.container.newBackgroundContext()
   1317         return ctx.performAndWait {
   1318             if name.hasPrefix("game-") {
   1319                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1320                 req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   1321                 req.fetchLimit = 1
   1322                 guard let entity = try? ctx.fetch(req).first else { return nil }
   1323                 return RecordSerializer.gameRecord(
   1324                     from: entity,
   1325                     recordID: recordID,
   1326                     includePuzzleSource: entity.ckSystemFields == nil
   1327                 )
   1328             } else if name.hasPrefix("moves-") {
   1329                 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
   1330                 req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   1331                 req.fetchLimit = 1
   1332                 guard let entity = try? ctx.fetch(req).first,
   1333                       let gameID = entity.game?.id,
   1334                       let authorID = entity.authorID,
   1335                       let deviceID = entity.deviceID,
   1336                       let updatedAt = entity.updatedAt
   1337                 else { return nil }
   1338                 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
   1339                 let value = MovesValue(
   1340                     gameID: gameID,
   1341                     authorID: authorID,
   1342                     deviceID: deviceID,
   1343                     cells: cells,
   1344                     updatedAt: updatedAt
   1345                 )
   1346                 return try? RecordSerializer.movesRecord(
   1347                     from: value,
   1348                     zone: zoneID,
   1349                     systemFields: entity.ckSystemFields
   1350                 )
   1351             } else if name.hasPrefix("player-") {
   1352                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   1353                 req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   1354                 req.fetchLimit = 1
   1355                 guard let entity = try? ctx.fetch(req).first,
   1356                       let gameID = entity.game?.id,
   1357                       let authorID = entity.authorID,
   1358                       let renderedName = entity.name,
   1359                       let updatedAt = entity.updatedAt
   1360                 else { return nil }
   1361                 let selection: PlayerSelection?
   1362                 if let row = entity.selRow,
   1363                    let col = entity.selCol,
   1364                    let dir = entity.selDir,
   1365                    let direction = Puzzle.Direction(rawValue: dir.intValue) {
   1366                     selection = PlayerSelection(
   1367                         row: row.intValue,
   1368                         col: col.intValue,
   1369                         direction: direction
   1370                     )
   1371                 } else {
   1372                     selection = nil
   1373                 }
   1374                 return RecordSerializer.playerRecord(
   1375                     gameID: gameID,
   1376                     authorID: authorID,
   1377                     name: renderedName,
   1378                     updatedAt: updatedAt,
   1379                     selection: selection,
   1380                     zone: zoneID,
   1381                     systemFields: entity.ckSystemFields
   1382                 )
   1383             }
   1384             return nil
   1385         }
   1386     }
   1387 
   1388     // MARK: - Incoming record application
   1389 
   1390     private nonisolated func applyPlayerRecord(
   1391         _ record: CKRecord,
   1392         in ctx: NSManagedObjectContext
   1393     ) {
   1394         let ckName = record.recordID.recordName
   1395         guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else {
   1396             return
   1397         }
   1398         guard let renderedName = record["name"] as? String else { return }
   1399         let updatedAt = record["updatedAt"] as? Date
   1400             ?? record.modificationDate
   1401             ?? Date()
   1402 
   1403         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   1404         req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
   1405         req.fetchLimit = 1
   1406 
   1407         let entity: PlayerEntity
   1408         if let existing = try? ctx.fetch(req).first {
   1409             entity = existing
   1410         } else {
   1411             let game = RecordSerializer.ensureGameEntity(
   1412                 forGameID: gameID,
   1413                 zoneID: record.recordID.zoneID,
   1414                 in: ctx
   1415             )
   1416             entity = PlayerEntity(context: ctx)
   1417             entity.game = game
   1418         }
   1419 
   1420         // Always adopt the server's system fields — that's etag tracking and
   1421         // is independent of which side has the freshest data. The value
   1422         // fields, however, are only adopted when the incoming record is at
   1423         // least as new as what we have locally; otherwise a stale-but-current
   1424         // server record (e.g. our own pending writes haven't landed yet)
   1425         // would clobber the user's live selection on every fetch.
   1426         entity.ckRecordName = ckName
   1427         entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
   1428         entity.authorID = authorID
   1429         let localUpdatedAt = entity.updatedAt
   1430         let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true
   1431         guard incomingIsFresher else { return }
   1432         // An empty `name` is what older builds shipped from the selection publisher
   1433         // before the fix; treat it as "no information" rather than letting it
   1434         // clobber a previously-resolved name.
   1435         if !renderedName.isEmpty {
   1436             entity.name = renderedName
   1437         }
   1438         entity.updatedAt = updatedAt
   1439         if let selection = RecordSerializer.parsePlayerSelection(from: record) {
   1440             entity.selRow = NSNumber(value: Int64(selection.row))
   1441             entity.selCol = NSNumber(value: Int64(selection.col))
   1442             entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
   1443         } else {
   1444             entity.selRow = nil
   1445             entity.selCol = nil
   1446             entity.selDir = nil
   1447         }
   1448     }
   1449 
   1450     /// Merges every device's `MovesEntity` row for `gameID` and reconciles the
   1451     /// `CellEntity` cache against the resulting grid. Must be called inside a
   1452     /// `performAndWait` block on the same context.
   1453     private nonisolated func replayCellCache(
   1454         for gameID: UUID,
   1455         in ctx: NSManagedObjectContext
   1456     ) {
   1457         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1458         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1459         gameReq.fetchLimit = 1
   1460         guard let game = try? ctx.fetch(gameReq).first else { return }
   1461 
   1462         let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
   1463         movesReq.predicate = NSPredicate(format: "game == %@", game)
   1464         let movesEntities = (try? ctx.fetch(movesReq)) ?? []
   1465         let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) }
   1466         let gridState = GridStateMerger.merge(values)
   1467 
   1468         let existingCells = (game.cells as? Set<CellEntity>) ?? []
   1469         var byPosition: [GridPosition: CellEntity] = [:]
   1470         for cell in existingCells {
   1471             byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
   1472         }
   1473 
   1474         for (pos, gridCell) in gridState {
   1475             let cell: CellEntity
   1476             if let existing = byPosition[pos] {
   1477                 cell = existing
   1478             } else {
   1479                 cell = CellEntity(context: ctx)
   1480                 cell.game = game
   1481                 cell.row = Int16(pos.row)
   1482                 cell.col = Int16(pos.col)
   1483             }
   1484             cell.letter = gridCell.letter
   1485             cell.markKind = gridCell.markKind
   1486             cell.checkedWrong = gridCell.checkedWrong
   1487             cell.letterAuthorID = gridCell.authorID
   1488         }
   1489 
   1490         for (pos, cell) in byPosition where gridState[pos] == nil {
   1491             cell.letter = ""
   1492             cell.markKind = 0
   1493             cell.checkedWrong = false
   1494             cell.letterAuthorID = nil
   1495         }
   1496     }
   1497 
   1498     /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row
   1499     /// is missing required fields (e.g. an unpopulated stub from a partial
   1500     /// fetch).
   1501     private nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? {
   1502         guard let gameID = entity.game?.id,
   1503               let authorID = entity.authorID,
   1504               let deviceID = entity.deviceID,
   1505               let updatedAt = entity.updatedAt
   1506         else { return nil }
   1507         let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
   1508         return MovesValue(
   1509             gameID: gameID,
   1510             authorID: authorID,
   1511             deviceID: deviceID,
   1512             cells: cells,
   1513             updatedAt: updatedAt
   1514         )
   1515     }
   1516 
   1517     // MARK: - Event handlers
   1518 
   1519     private func handleFetchedDatabaseChanges(
   1520         _ event: CKSyncEngine.Event.FetchedDatabaseChanges,
   1521         isPrivate: Bool
   1522     ) async {
   1523         let src = currentFetchSource ?? "framework"
   1524         await trace(
   1525             "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " +
   1526             "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
   1527         )
   1528 
   1529         // Private-DB zone deletions reflect the user removing one of their own
   1530         // games on another device — hard-delete locally so the row stops
   1531         // hanging around forever. Shared-DB zone deletions reflect the owner
   1532         // removing this account from the share — mark access-revoked instead
   1533         // so the UI can surface "no longer have access" rather than silently
   1534         // vanishing the row mid-game. Modifications on the shared DB also
   1535         // create placeholder GameEntities for newly-joined shares.
   1536         let ctx = persistence.container.newBackgroundContext()
   1537         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1538         let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait {
   1539             var removed: [UUID] = []
   1540             var revoked: [UUID] = []
   1541             if !isPrivate {
   1542                 for mod in event.modifications {
   1543                     let zoneID = mod.zoneID
   1544                     let zoneName = zoneID.zoneName
   1545                     guard zoneName.hasPrefix("game-") else { continue }
   1546                     let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1547                     req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1548                     req.fetchLimit = 1
   1549                     if (try? ctx.fetch(req).first) == nil {
   1550                         // Placeholder until the Game record arrives.
   1551                         let entity = GameEntity(context: ctx)
   1552                         let uuidString = String(zoneName.dropFirst("game-".count))
   1553                         entity.id = UUID(uuidString: uuidString)
   1554                         entity.ckRecordName = zoneName
   1555                         entity.ckZoneName = zoneName
   1556                         entity.ckZoneOwnerName = zoneID.ownerName
   1557                         entity.databaseScope = 1
   1558                         entity.title = "Joining\u{2026}"
   1559                         entity.puzzleSource = ""
   1560                         entity.createdAt = Date()
   1561                         entity.updatedAt = Date()
   1562                     }
   1563                 }
   1564             }
   1565             for deletion in event.deletions {
   1566                 let zoneName = deletion.zoneID.zoneName
   1567                 guard zoneName.hasPrefix("game-") else { continue }
   1568                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1569                 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1570                 req.fetchLimit = 1
   1571                 guard let entity = try? ctx.fetch(req).first else { continue }
   1572                 if isPrivate {
   1573                     if let id = entity.id { removed.append(id) }
   1574                     ctx.delete(entity)
   1575                 } else {
   1576                     entity.isAccessRevoked = true
   1577                     if let id = entity.id { revoked.append(id) }
   1578                 }
   1579             }
   1580             if ctx.hasChanges {
   1581                 do {
   1582                     try ctx.save()
   1583                 } catch {
   1584                     let nsError = error as NSError
   1585                     print(
   1586                         "SyncEngine: db-changes ctx.save failed — domain=\(nsError.domain) " +
   1587                         "code=\(nsError.code) \(nsError.localizedDescription)"
   1588                     )
   1589                 }
   1590             }
   1591             return (removed, revoked)
   1592         }
   1593 
   1594         for id in removedIDs {
   1595             if let cb = onGameRemoved { await cb(id) }
   1596         }
   1597         for id in revokedIDs {
   1598             if let cb = onGameAccessRevoked { await cb(id) }
   1599         }
   1600     }
   1601 
   1602     private func handleFetchedRecordZoneChanges(
   1603         _ event: CKSyncEngine.Event.FetchedRecordZoneChanges,
   1604         isPrivate: Bool
   1605     ) async {
   1606         let scope: Int16 = isPrivate ? 0 : 1
   1607         let src = currentFetchSource ?? "framework"
   1608         await trace(
   1609             "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " +
   1610             "\(event.modifications.count) modifications, \(event.deletions.count) deletions"
   1611         )
   1612         if !isPrivate, !loggedFirstSharedPushPayload, src == "push",
   1613            event.modifications.count + event.deletions.count > 0 {
   1614             loggedFirstSharedPushPayload = true
   1615             await trace("✅ first shared-DB push payload received — silent-push path is live")
   1616         }
   1617 
   1618         let ctx = persistence.container.newBackgroundContext()
   1619         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1620         let localAuthorID = await currentLocalAuthorID()
   1621         let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait {
   1622             var movesUpdated = Set<UUID>()
   1623             var affected = Set<UUID>()
   1624             var pings: [Ping] = []
   1625             for mod in event.modifications {
   1626                 let record = mod.record
   1627                 switch record.recordType {
   1628                 case "Game":
   1629                     let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope)
   1630                     if let id = entity.id { affected.insert(id) }
   1631                 case "Moves":
   1632                     if let value = RecordSerializer.parseMovesRecord(record) {
   1633                         RecordSerializer.applyMovesRecord(
   1634                             record,
   1635                             value: value,
   1636                             to: ctx,
   1637                             localAuthorID: localAuthorID
   1638                         )
   1639                         movesUpdated.insert(value.gameID)
   1640                         affected.insert(value.gameID)
   1641                     }
   1642                 case "Player":
   1643                     if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
   1644                         self.applyPlayerRecord(record, in: ctx)
   1645                         affected.insert(gameID)
   1646                     }
   1647                 case "Ping":
   1648                     if let ping = Self.parsePingRecord(record) {
   1649                         pings.append(ping)
   1650                     }
   1651                 default:
   1652                     break
   1653                 }
   1654             }
   1655             for deletion in event.deletions {
   1656                 self.applyDeletion(
   1657                     recordID: deletion.recordID,
   1658                     recordType: deletion.recordType,
   1659                     in: ctx
   1660                 )
   1661                 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) {
   1662                     affected.insert(id)
   1663                 }
   1664             }
   1665             for gameID in movesUpdated {
   1666                 self.replayCellCache(for: gameID, in: ctx)
   1667             }
   1668             // CKSyncEngine advances its change token whenever the delegate
   1669             // returns from fetchedRecordZoneChanges, regardless of whether we
   1670             // persisted anything. A silent failure here means the records are
   1671             // gone from the engine's "to deliver" set — they won't come back
   1672             // without a `resetSyncState`. Surface failures so we can act.
   1673             if ctx.hasChanges {
   1674                 do {
   1675                     try ctx.save()
   1676                 } catch {
   1677                     let nsError = error as NSError
   1678                     print(
   1679                         "SyncEngine: fetchedRecordZoneChanges ctx.save failed " +
   1680                         "— domain=\(nsError.domain) code=\(nsError.code) " +
   1681                         "\(nsError.localizedDescription)"
   1682                     )
   1683                 }
   1684             }
   1685             return (movesUpdated, affected, pings)
   1686         }
   1687 
   1688         if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
   1689             await onRemoteMovesUpdated(movesUpdatedGameIDs)
   1690         }
   1691         if let onPings, !pings.isEmpty {
   1692             await onPings(pings)
   1693         }
   1694         if !affectedGameIDs.isEmpty {
   1695             NotificationCenter.default.post(
   1696                 name: .playerRosterShouldRefresh,
   1697                 object: nil,
   1698                 userInfo: ["gameIDs": affectedGameIDs]
   1699             )
   1700         }
   1701     }
   1702 
   1703     private nonisolated static func parsePingRecord(_ record: CKRecord) -> Ping? {
   1704         let name = record.recordID.recordName
   1705         let gameID: UUID?
   1706         if name.hasPrefix("ping-") {
   1707             let rest = name.dropFirst("ping-".count)
   1708             gameID = UUID(uuidString: String(rest.prefix(36)))
   1709         } else if record.recordID.zoneID.zoneName.hasPrefix("game-") {
   1710             gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count)))
   1711         } else {
   1712             gameID = nil
   1713         }
   1714         guard let gameID,
   1715               let authorID = record["authorID"] as? String,
   1716               let kindRaw = record["kind"] as? String,
   1717               let kind = PingKind(rawValue: kindRaw)
   1718         else { return nil }
   1719         let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:))
   1720         return Ping(
   1721             recordName: name,
   1722             gameID: gameID,
   1723             authorID: authorID,
   1724             playerName: (record["playerName"] as? String) ?? "",
   1725             puzzleTitle: (record["puzzleTitle"] as? String) ?? "",
   1726             kind: kind,
   1727             scope: scope
   1728         )
   1729     }
   1730 
   1731     private nonisolated func applyDeletion(
   1732         recordID: CKRecord.ID,
   1733         recordType: CKRecord.RecordType,
   1734         in ctx: NSManagedObjectContext
   1735     ) {
   1736         let name = recordID.recordName
   1737         let entityName: String
   1738         if name.hasPrefix("moves-") {
   1739             entityName = "MovesEntity"
   1740         } else if name.hasPrefix("player-") {
   1741             entityName = "PlayerEntity"
   1742         } else if name.hasPrefix("game-") {
   1743             entityName = "GameEntity"
   1744         } else {
   1745             switch recordType {
   1746             case "Moves": entityName = "MovesEntity"
   1747             case "Player": entityName = "PlayerEntity"
   1748             case "Game": entityName = "GameEntity"
   1749             default: return
   1750             }
   1751         }
   1752         let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
   1753         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   1754         req.fetchLimit = 1
   1755         if let obj = try? ctx.fetch(req).first {
   1756             ctx.delete(obj)
   1757         }
   1758     }
   1759 
   1760     private func handleSentRecordZoneChanges(
   1761         _ event: CKSyncEngine.Event.SentRecordZoneChanges,
   1762         isPrivate: Bool
   1763     ) async {
   1764         await trace(
   1765             "\(isPrivate ? "private" : "shared") sent: " +
   1766             "\(event.savedRecords.count) saved, " +
   1767             "\(event.failedRecordSaves.count) failed, " +
   1768             "\(event.deletedRecordIDs.count) deleted"
   1769         )
   1770         for record in event.savedRecords {
   1771             let name = record.recordID.recordName
   1772             if name.hasPrefix("ping-") {
   1773                 pendingPings.removeValue(forKey: name)
   1774             }
   1775         }
   1776         let ctx = persistence.container.newBackgroundContext()
   1777         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1778         let (failureMessages, orphanedZones): ([String], Set<CKRecordZone.ID>) = ctx.performAndWait {
   1779             var messages: [String] = []
   1780             var orphaned = Set<CKRecordZone.ID>()
   1781             for record in event.savedRecords {
   1782                 self.writeBackSystemFields(record: record, in: ctx)
   1783             }
   1784             for failure in event.failedRecordSaves {
   1785                 let name = failure.record.recordID.recordName
   1786                 let err = failure.error as NSError
   1787                 if err.domain == CKErrorDomain,
   1788                    err.code == CKError.zoneNotFound.rawValue {
   1789                     orphaned.insert(failure.record.recordID.zoneID)
   1790                 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
   1791                     messages.append(
   1792                         "send: recovered stale system fields for \(name) from CloudKit server record"
   1793                     )
   1794                 }
   1795                 let userInfo = err.userInfo
   1796                     .map { "\($0.key)=\($0.value)" }
   1797                     .joined(separator: " | ")
   1798                 messages.append(
   1799                     "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)"
   1800                 )
   1801             }
   1802             if ctx.hasChanges {
   1803                 do {
   1804                     try ctx.save()
   1805                 } catch {
   1806                     let nsError = error as NSError
   1807                     messages.append(
   1808                         "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
   1809                     )
   1810                 }
   1811             }
   1812             return (messages, orphaned)
   1813         }
   1814         if !orphanedZones.isEmpty {
   1815             await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
   1816         }
   1817         for message in failureMessages {
   1818             await trace(message)
   1819         }
   1820     }
   1821 
   1822     /// Reacts to per-record `.zoneNotFound` failures discovered during a push
   1823     /// by reflecting the missing-zone reality locally. The framework reports
   1824     /// the same failure on every retry without ever clearing the queued change,
   1825     /// so we drop pending sends that target the zone, mirror the fetch-side
   1826     /// deletion handling on the local game (delete on private, mark
   1827     /// access-revoked on shared), and notify upstream observers. Without this,
   1828     /// `Last Error` stays stuck on `Failed to send changes` indefinitely.
   1829     /// Internal-rather-than-private so the test suite can drive it directly;
   1830     /// `CKSyncEngine.Event` payloads have no public initializer so we cannot
   1831     /// exercise `handleSentRecordZoneChanges` end-to-end.
   1832     func applyZoneOrphaning(
   1833         _ zones: Set<CKRecordZone.ID>,
   1834         isPrivate: Bool
   1835     ) async {
   1836         let engine = isPrivate ? privateEngine : sharedEngine
   1837         if let engine {
   1838             let toRemove = engine.state.pendingRecordZoneChanges.filter { change in
   1839                 switch change {
   1840                 case .saveRecord(let id):
   1841                     return zones.contains(id.zoneID)
   1842                 case .deleteRecord(let id):
   1843                     return zones.contains(id.zoneID)
   1844                 @unknown default:
   1845                     return false
   1846                 }
   1847             }
   1848             if !toRemove.isEmpty {
   1849                 engine.state.remove(pendingRecordZoneChanges: toRemove)
   1850             }
   1851         }
   1852 
   1853         for (name, _) in pendingPings {
   1854             guard let gameID = gameID(fromRecordName: name) else { continue }
   1855             if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) {
   1856                 pendingPings.removeValue(forKey: name)
   1857             }
   1858         }
   1859 
   1860         let ctx = persistence.container.newBackgroundContext()
   1861         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1862         let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait {
   1863             var removed: [UUID] = []
   1864             var revoked: [UUID] = []
   1865             for zone in zones {
   1866                 let zoneName = zone.zoneName
   1867                 guard zoneName.hasPrefix("game-") else { continue }
   1868                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1869                 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1870                 req.fetchLimit = 1
   1871                 guard let entity = try? ctx.fetch(req).first else { continue }
   1872                 if isPrivate {
   1873                     if let id = entity.id { removed.append(id) }
   1874                     ctx.delete(entity)
   1875                 } else {
   1876                     if !entity.isAccessRevoked, let id = entity.id {
   1877                         revoked.append(id)
   1878                     }
   1879                     entity.isAccessRevoked = true
   1880                 }
   1881             }
   1882             if ctx.hasChanges {
   1883                 do {
   1884                     try ctx.save()
   1885                 } catch {
   1886                     let nsError = error as NSError
   1887                     print(
   1888                         "SyncEngine: orphan-zone ctx.save failed — domain=\(nsError.domain) " +
   1889                         "code=\(nsError.code) \(nsError.localizedDescription)"
   1890                     )
   1891                 }
   1892             }
   1893             return (removed, revoked)
   1894         }
   1895 
   1896         await trace(
   1897             "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " +
   1898             zones.map(\.zoneName).sorted().joined(separator: ", ")
   1899         )
   1900 
   1901         for id in removedIDs {
   1902             if let cb = onGameRemoved { await cb(id) }
   1903         }
   1904         for id in revokedIDs {
   1905             if let cb = onGameAccessRevoked { await cb(id) }
   1906         }
   1907     }
   1908 
   1909     /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the
   1910     /// error payload often includes the current server record. Adopt only that
   1911     /// record's system fields so a retry can use the fresh change tag while
   1912     /// preserving the local values that caused the pending save.
   1913     private nonisolated func recoverServerChangedSave(
   1914         _ error: Error,
   1915         failedRecordName: String,
   1916         in ctx: NSManagedObjectContext
   1917     ) -> Bool {
   1918         let nsError = error as NSError
   1919         guard nsError.domain == CKErrorDomain,
   1920               nsError.code == CKError.serverRecordChanged.rawValue,
   1921               let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord,
   1922               serverRecord.recordID.recordName == failedRecordName
   1923         else { return false }
   1924 
   1925         writeBackSystemFields(record: serverRecord, in: ctx)
   1926         return true
   1927     }
   1928 
   1929     private nonisolated func writeBackSystemFields(
   1930         record: CKRecord,
   1931         in ctx: NSManagedObjectContext
   1932     ) {
   1933         let name = record.recordID.recordName
   1934         let entityName: String
   1935         if name.hasPrefix("moves-") { entityName = "MovesEntity" }
   1936         else if name.hasPrefix("player-") { entityName = "PlayerEntity" }
   1937         else if name.hasPrefix("game-") { entityName = "GameEntity" }
   1938         else { return }
   1939 
   1940         let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
   1941         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   1942         req.fetchLimit = 1
   1943         guard let obj = try? ctx.fetch(req).first else { return }
   1944         obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
   1945         if entityName == "GameEntity" {
   1946             obj.setValue(Date(), forKey: "lastSyncedAt")
   1947         }
   1948     }
   1949 
   1950     // MARK: - Logging helpers
   1951 
   1952     private nonisolated func trace(_ message: String) {
   1953         print("SyncEngine: \(message)")
   1954     }
   1955 
   1956     private nonisolated func describe(_ error: Error) -> String {
   1957         let nsError = error as NSError
   1958         return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
   1959     }
   1960 
   1961     private nonisolated func describeStatus(_ status: CKAccountStatus) -> String {
   1962         switch status {
   1963         case .available: return "available"
   1964         case .noAccount: return "noAccount"
   1965         case .restricted: return "restricted"
   1966         case .couldNotDetermine: return "couldNotDetermine"
   1967         case .temporarilyUnavailable: return "temporarilyUnavailable"
   1968         @unknown default: return "unknown(\(status.rawValue))"
   1969         }
   1970     }
   1971 }
   1972 
   1973 // MARK: - CKSyncEngineDelegate
   1974 
   1975 extension SyncEngine: CKSyncEngineDelegate {
   1976     func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
   1977         let isPrivate = syncEngine === privateEngine
   1978         switch event {
   1979         case .stateUpdate(let e):
   1980             saveEngineState(e.stateSerialization, isPrivate: isPrivate)
   1981 
   1982         case .accountChange(let e):
   1983             await trace("account change: \(e.changeType)")
   1984             if let onAccountChange { await onAccountChange() }
   1985 
   1986         case .fetchedDatabaseChanges(let e):
   1987             await handleFetchedDatabaseChanges(e, isPrivate: isPrivate)
   1988 
   1989         case .fetchedRecordZoneChanges(let e):
   1990             await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate)
   1991 
   1992         case .sentDatabaseChanges:
   1993             break
   1994 
   1995         case .sentRecordZoneChanges(let e):
   1996             await handleSentRecordZoneChanges(e, isPrivate: isPrivate)
   1997 
   1998         case .willFetchChanges, .didFetchChanges,
   1999              .willFetchRecordZoneChanges, .didFetchRecordZoneChanges,
   2000              .willSendChanges, .didSendChanges:
   2001             break
   2002 
   2003         @unknown default:
   2004             break
   2005         }
   2006     }
   2007 
   2008     func nextRecordZoneChangeBatch(
   2009         _ context: CKSyncEngine.SendChangesContext,
   2010         syncEngine: CKSyncEngine
   2011     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2012         let pending = syncEngine.state.pendingRecordZoneChanges
   2013         guard !pending.isEmpty else { return nil }
   2014         let pingSnapshot = pendingPings
   2015         return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
   2016             guard let self else { return nil }
   2017             return self.buildRecord(for: recordID, pings: pingSnapshot)
   2018         }
   2019     }
   2020 }