crossmate

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

RecordApplier.swift (35650B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 struct BatchEffects {
      6     var movesUpdated = Set<UUID>()
      7     /// Games whose roster-relevant state changed in this batch — a Player
      8     /// record (name / selection / presenceUntil), a Game record (share metadata), a
      9     /// deletion (participant removal), or a *new* contributor's first Moves
     10     /// row (a participant the roster can only discover from their moves; see
     11     /// `applyMovesRecord`'s `onNewAuthor`). Drives `.playerRosterShouldRefresh`.
     12     /// A repeat Moves row from a known contributor is excluded: it changes the
     13     /// grid, not the roster, and during a co-solve flurry those land at
     14     /// keystroke cadence — refreshing the roster (and re-evaluating the grid
     15     /// view) on each one was pure overhead.
     16     var rosterRelevant = Set<UUID>()
     17     var pings: [Ping] = []
     18     var playersUpdated = Set<UUID>()
     19     var playerPresenceChanged = Set<UUID>()
     20     var engagementChanged = Set<UUID>()
     21     /// Games whose inbound Game record changed the notification content key, so
     22     /// the caller re-mirrors the App Group key directory the NSE reads.
     23     var contentKeysChanged = Set<UUID>()
     24     /// Game record names whose inbound copy carried a push credential *older*
     25     /// (by rotation generation) than the local one — a stale device's re-push
     26     /// landed on the server. The local value was kept and flagged pending; the
     27     /// caller re-enqueues these records so the rotated credential heals the
     28     /// server copy.
     29     var staleCredentialRecords = Set<String>()
     30     /// Games whose inbound zone-wide `CKShare` lost a previously accepted
     31     /// participant — someone left or was removed. Every remaining device that
     32     /// observes this rotates the game's push credentials (record-level LWW
     33     /// converges concurrent rotations), ending the departed device's ability
     34     /// to receive, publish, or re-subscribe to the game's pushes.
     35     var credentialRotations = Set<UUID>()
     36     var removed = Set<UUID>()
     37     /// Per-game incoming read cursor from one of *our own* devices: the
     38     /// presence-lease time plus the sibling's "last viewed" cutoff that it
     39     /// shipped on its `Player.viewedAt` (nil until it has left a game). Drives
     40     /// cross-device baseline adoption.
     41     var readCursors: [(UUID, Date, Date?)] = []
     42     /// Games that just transitioned to completed via an inbound Game record, so
     43     /// this device uploads its journal for replay even though it didn't run the
     44     /// local completion path.
     45     var completedTransitions = Set<UUID>()
     46     /// Games for which an inbound `Journal` record landed — wakes a waiting
     47     /// finish-banner replay and refreshes any provisional Chronicle.
     48     var journalsSynced = Set<UUID>()
     49     /// Account-level push address decisions seen in the private account zone.
     50     var accountPushAddresses: [String] = []
     51     /// Account-level push *secret* decisions seen in the private account zone,
     52     /// each with its generation. Drive re-derivation of every per-game push
     53     /// address (see `RecordSerializer.deriveGameAddress`); the version gates
     54     /// adoption so a stale inbound copy can't undo a rotation.
     55     var accountPushSecrets: [(secret: String, version: Int64)] = []
     56     /// Versions of *our own* name Decision echoed back by sync. Adopted into
     57     /// the local rename counter so the next rename supersedes the highest
     58     /// generation any of this account's devices has published.
     59     var selfNameVersions: [Int64] = []
     60     /// A `name` or `nickname` Decision changed a friend row in this batch —
     61     /// either side of an App Group nickname-directory entry, so the caller
     62     /// rebuilds the directory after the batch saves.
     63     var friendNamesChanged = false
     64     /// Friend author IDs whose block Decision changed in this batch.
     65     /// `GameEntity.isHidden` is a local projection of the block table, so the
     66     /// app re-derives affected games once the batch has landed.
     67     var blockedFriendAuthorIDs = Set<String>()
     68     /// Games whose owner/player/moves metadata changed in this batch. The app
     69     /// checks only these games against the already-synced block table so a
     70     /// newly-arrived game featuring a blocked author is hidden without a full
     71     /// library scan.
     72     var visibilityCandidateGameIDs = Set<UUID>()
     73     /// Diagnostics emitted while applying the batch inside `ctx.perform` —
     74     /// chiefly Core Data fetch/save failures, which silently drop records (the
     75     /// engine's change token has already advanced, so they never redeliver).
     76     /// The batch context can't `await`, so messages accumulate here and the
     77     /// caller traces them afterwards — the same shape as the send path's
     78     /// failure messages. A bare `print` is invisible in Production, where the
     79     /// on-device diagnostics log is the only observability.
     80     var traces: [String] = []
     81 }
     82 
     83 extension SyncEngine {
     84     func applyDirectRecordZoneChanges(
     85         records: [CKRecord],
     86         deletions: [(CKRecord.ID, CKRecord.RecordType)],
     87         scopeValue: DatabaseScope
     88     ) async {
     89         guard !records.isEmpty || !deletions.isEmpty else { return }
     90         let ctx = persistence.container.newBackgroundContext()
     91         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
     92         let localAuthorID = await currentLocalAuthorID()
     93         let effects: BatchEffects = await ctx.perform {
     94             var effects = BatchEffects()
     95             for record in records {
     96                 guard !RecordSerializer.isGameScopedRecordType(record.recordType)
     97                     || RecordSerializer.isTrustedGameScopedRecord(record)
     98                 else {
     99                     effects.traces.append(
    100                         "rejected untrusted direct record \(record.recordType) " +
    101                         "\(record.recordID.recordName) in \(record.recordID.zoneID.zoneName)"
    102                     )
    103                     continue
    104                 }
    105                 switch record.recordType {
    106                 case "Game":
    107                     let entity = RecordSerializer.applyGameRecord(
    108                         record,
    109                         to: ctx,
    110                         databaseScope: scopeValue,
    111                         onEngagementChange: { effects.engagementChanged.insert($0) },
    112                         onCompletedTransition: { effects.completedTransitions.insert($0) },
    113                         onContentKeyChange: { effects.contentKeysChanged.insert($0) },
    114                         onStaleCredentials: { effects.staleCredentialRecords.insert($0) },
    115                         onDiagnostic: { effects.traces.append($0) }
    116                     )
    117                     if let id = entity.id {
    118                         effects.rosterRelevant.insert(id)
    119                         effects.visibilityCandidateGameIDs.insert(id)
    120                     }
    121                 case "Moves":
    122                     if let value = RecordSerializer.parseMovesRecord(record) {
    123                         effects.visibilityCandidateGameIDs.insert(value.gameID)
    124                         let cellsChanged = RecordSerializer.applyMovesRecord(
    125                             record,
    126                             value: value,
    127                             to: ctx,
    128                             databaseScope: scopeValue,
    129                             localAuthorID: localAuthorID,
    130                             onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) }
    131                         )
    132                         if cellsChanged { effects.movesUpdated.insert(value.gameID) }
    133                     }
    134                 case "Player":
    135                     if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
    136                         effects.visibilityCandidateGameIDs.insert(gameID)
    137                         self.applyPlayerRecord(
    138                             record,
    139                             in: ctx,
    140                             databaseScope: scopeValue,
    141                             localAuthorID: localAuthorID,
    142                             onFirstTime: { effects.playersUpdated.insert($0) },
    143                             onPresenceChange: { effects.playerPresenceChanged.insert($0) },
    144                             onReadCursor: { effects.readCursors.append(($0, $1, $2)) }
    145                         )
    146                         effects.rosterRelevant.insert(gameID)
    147                     }
    148                 case Archive.recordType, Archive.legacyRecordType:
    149                     if let id = self.applyArchiveRecord(
    150                         record,
    151                         in: ctx,
    152                         onDiagnostic: { effects.traces.append($0) }
    153                     ) {
    154                         effects.rosterRelevant.insert(id)
    155                         effects.visibilityCandidateGameIDs.insert(id)
    156                     }
    157                 default:
    158                     break
    159                 }
    160             }
    161             for deletion in deletions {
    162                 guard !RecordSerializer.isGameScopedRecordType(deletion.1)
    163                     || RecordSerializer.isTrustedGameScopedDeletion(
    164                         recordID: deletion.0,
    165                         recordType: deletion.1
    166                     )
    167                 else {
    168                     effects.traces.append(
    169                         "rejected untrusted direct deletion \(deletion.1) " +
    170                         "\(deletion.0.recordName) in \(deletion.0.zoneID.zoneName)"
    171                     )
    172                     continue
    173                 }
    174                 self.applyDeletion(
    175                     recordID: deletion.0,
    176                     recordType: deletion.1,
    177                     databaseScope: scopeValue,
    178                     in: ctx
    179                 )
    180                 if let id = self.gameID(fromRecordName: deletion.0.recordName) {
    181                     effects.rosterRelevant.insert(id)
    182                     effects.visibilityCandidateGameIDs.insert(id)
    183                 }
    184             }
    185             for gameID in effects.movesUpdated {
    186                 effects.traces += self.replayCellCache(for: gameID, in: ctx)
    187             }
    188             if ctx.hasChanges {
    189                 do {
    190                     try ctx.save()
    191                 } catch {
    192                     let nsError = error as NSError
    193                     effects.traces.append(
    194                         "direct-push ctx.save FAILED " +
    195                         "— domain=\(nsError.domain) code=\(nsError.code) " +
    196                         "\(nsError.localizedDescription)"
    197                     )
    198                 }
    199             }
    200             // Re-mirror the App Group key directory once the batch is saved, so
    201             // a just-adopted content key is available to the NSE immediately.
    202             if !effects.contentKeysChanged.isEmpty {
    203                 GameEntity.rebuildContentKeyDirectory(in: ctx)
    204             }
    205             return effects
    206         }
    207 
    208         for message in effects.traces {
    209             await trace(message)
    210         }
    211         if let onGameVisibilityCandidates, !effects.visibilityCandidateGameIDs.isEmpty {
    212             await onGameVisibilityCandidates(effects.visibilityCandidateGameIDs)
    213         }
    214         if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty {
    215             await onRemoteMovesUpdated(effects.movesUpdated)
    216         }
    217         if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty {
    218             await onRemotePlayersUpdated(effects.playersUpdated)
    219         }
    220         if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty {
    221             await onRemotePlayerPresenceChanged(effects.playerPresenceChanged)
    222         }
    223         if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty {
    224             await onRemoteEngagementChanged(effects.engagementChanged)
    225         }
    226         if let onIncomingReadCursor, !effects.readCursors.isEmpty {
    227             await onIncomingReadCursor(effects.readCursors)
    228         }
    229         // A game just learned it's complete via sync: upload this device's
    230         // journal (no-op if it logged nothing) so replay can converge. The
    231         // enqueue defers its CKSyncEngine drain via `sendChangesDetached`.
    232         if let localAuthorID, !localAuthorID.isEmpty {
    233             for id in effects.completedTransitions {
    234                 enqueueJournalUpload(gameID: id, authorID: localAuthorID)
    235             }
    236         }
    237         if let onGameCompleted {
    238             for id in effects.completedTransitions {
    239                 await onGameCompleted(id)
    240             }
    241         }
    242         let deletedPings = deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in
    243             let recordName = deletion.0.recordName
    244             guard recordName.hasPrefix("ping-"),
    245                   let gameID = gameID(fromRecordName: recordName)
    246             else { return nil }
    247             return (recordName, gameID)
    248         }
    249         if let onPingDeleted, !deletedPings.isEmpty {
    250             await onPingDeleted(deletedPings)
    251         }
    252         if !effects.rosterRelevant.isEmpty {
    253             NotificationCenter.default.post(
    254                 name: .playerRosterShouldRefresh,
    255                 object: nil,
    256                 userInfo: ["gameIDs": effects.rosterRelevant]
    257             )
    258         }
    259         // Re-push games whose inbound record tried to downgrade a rotated
    260         // credential: the local (newer) value was kept and marked pending, so
    261         // this send heals the server copy.
    262         for recordName in effects.staleCredentialRecords {
    263             enqueueGame(ckRecordName: recordName)
    264         }
    265         if let onRemoteCredentialsChanged, !effects.contentKeysChanged.isEmpty {
    266             await onRemoteCredentialsChanged(effects.contentKeysChanged)
    267         }
    268     }
    269 
    270     nonisolated func applyPlayerRecord(
    271         _ record: CKRecord,
    272         in ctx: NSManagedObjectContext,
    273         databaseScope: DatabaseScope = .private,
    274         localAuthorID: String?,
    275         onFirstTime: (UUID) -> Void,
    276         onPresenceChange: (UUID) -> Void,
    277         onReadCursor: (UUID, Date, Date?) -> Void
    278     ) {
    279         let ckName = record.recordID.recordName
    280         guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else {
    281             return
    282         }
    283         let renderedName = record["name"] as? String
    284         let updatedAt = record["updatedAt"] as? Date
    285             ?? record.modificationDate
    286             ?? Date()
    287 
    288         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    289         req.predicate = RecordSerializer.gameIdentityPredicate(
    290             recordName: ckName,
    291             zoneID: record.recordID.zoneID,
    292             databaseScope: databaseScope,
    293             entityPrefix: "game"
    294         )
    295         req.fetchLimit = 1
    296 
    297         let entity: PlayerEntity
    298         let foundExisting: Bool
    299         if let existing = try? ctx.fetch(req).first {
    300             entity = existing
    301             foundExisting = true
    302         } else {
    303             let game = RecordSerializer.ensureGameEntity(
    304                 forGameID: gameID,
    305                 zoneID: record.recordID.zoneID,
    306                 databaseScope: databaseScope,
    307                 in: ctx
    308             )
    309             entity = PlayerEntity(context: ctx)
    310             entity.game = game
    311             foundExisting = false
    312         }
    313 
    314         // Drop fetched snapshots older than what we already have. After a
    315         // successful push the writeback adopts the new etag; if a query
    316         // that started before the push lands later, applying its older
    317         // snapshot would downgrade our local etag and OpLock-fail the next
    318         // save (see `applyMovesRecord` for the same guard).
    319         if foundExisting,
    320            !RecordSerializer.incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
    321             return
    322         }
    323 
    324         let oldSelection = (entity.selRow, entity.selCol, entity.selDir)
    325         let hadSelection = oldSelection.0 != nil && oldSelection.1 != nil && oldSelection.2 != nil
    326         let oldUpdatedAt = entity.updatedAt
    327 
    328         // Adopt the server's system fields — that's etag tracking and is
    329         // independent of which side has the freshest data.
    330         entity.ckRecordName = ckName
    331         entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
    332         entity.authorID = authorID
    333 
    334         // The read cursor and session snapshot are account-scoped convergence
    335         // state — "what this account has already seen" — not the live cursor, so
    336         // they are adopted ahead of (and independent of) the selection's
    337         // `updatedAt` LWW below. A sibling commits the catch-up baseline on leave
    338         // (`handlePuzzleLeft`), which advances the read cursor and writes the
    339         // snapshot but does *not* bump `updatedAt`; the outbound record therefore
    340         // ships a stale `updatedAt`. Gating these on it would let a device with a
    341         // fresher local cursor drop the baseline and re-report the same moves as
    342         // a duplicate catch-up banner. The etag guard above already rejects
    343         // genuinely stale fetches. Adopting the cursor here is *not* monotonic:
    344         // `noteIncomingReadCursor` adopts the inbound value directly under
    345         // last-writer-wins, so a leaving sibling's past horizon *can* pull the
    346         // account horizon back below another sibling's live presence lease.
    347         // That collapse is bounded and self-healing: the still-present device
    348         // re-asserts its lease as soon as it processes the inbound close
    349         // (AppServices' incoming-cursor drain re-runs `publishReadCursor`
    350         // ahead of the 5-min refresh floor), and a foreground device marks
    351         // inbound peer moves read on arrival regardless. Keeping "A left
    352         // while C is still here" representable without the dip would need
    353         // per-device Player rows, which don't exist (one row per author).
    354         let previousPresenceUntil = entity.presenceUntil
    355         let previousViewedAt = entity.viewedAt
    356         let incomingPresenceUntil = RecordSerializer.parsePlayerPresenceUntil(from: record)
    357         entity.presenceUntil = incomingPresenceUntil
    358         let incomingReadThrough = RecordSerializer.parsePlayerReadThrough(from: record)
    359         entity.readThrough = incomingReadThrough
    360         entity.viewedAt = RecordSerializer.parsePlayerViewedAt(from: record)
    361         // `timeLog` is the device-keyed solve-time log. Touch it only when the
    362         // fetched record actually carries the field. A partial fetch (CloudQuery
    363         // restricts `desiredKeys`) or an older record omits it, and because the
    364         // log only ever grows, an absent field never means "cleared" — adopting
    365         // nil there would erase a peer's or a sibling's intervals until the next
    366         // full sync re-delivered them. Applied outside the `updatedAt` freshness
    367         // guard below: like the read cursor, a sibling's leave-write can ship a
    368         // stale `updatedAt`.
    369         if record.allKeys().contains("timeLog") {
    370             let incomingTimeLog = RecordSerializer.parsePlayerTimeLog(from: record)
    371             if authorID == localAuthorID {
    372                 // Our own record echoing back from a sibling: merge by device,
    373                 // keeping this device's own slot (we are its sole writer, and the
    374                 // sibling's copy may have dropped a session we have open now).
    375                 var local = TimeLog.decode(entity.timeLog)
    376                 local.merge(
    377                     inbound: TimeLog.decode(incomingTimeLog),
    378                     preservingDevice: RecordSerializer.localDeviceID
    379                 )
    380                 entity.timeLog = local.devices.isEmpty ? nil : TimeLog.encode(local)
    381             } else {
    382                 entity.timeLog = incomingTimeLog
    383             }
    384         }
    385         // Only surface the cursor when the row actually changed. A re-application
    386         // of values we already hold — e.g. a catch-up query snapshot racing this
    387         // device's in-flight lease save shares the old etag, so the freshness
    388         // guard above admits it — carries no new account-horizon information,
    389         // and adopting it would rewind the just-minted lease and trigger a
    390         // redundant re-assert save (the open-time double mint).
    391         if authorID == localAuthorID, let presenceUntil = incomingPresenceUntil,
    392            presenceUntil != previousPresenceUntil || entity.viewedAt != previousViewedAt {
    393             onReadCursor(gameID, presenceUntil, entity.viewedAt)
    394         }
    395         // Our own record coming back from another of this account's devices
    396         // carries that device's read watermark. Adopt it monotonically onto the
    397         // shared game so the unread badge clears here once any device has read,
    398         // mirroring how the presence lease converges via `onReadCursor` above.
    399         if authorID == localAuthorID,
    400            let incomingReadThrough,
    401            let game = entity.game,
    402            (game.readThroughAt ?? .distantPast) < incomingReadThrough {
    403             game.readThroughAt = incomingReadThrough
    404             let archiveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    405             archiveRequest.predicate = NSPredicate(
    406                 format: "id == %@",
    407                 Archive.archiveGameID(for: gameID) as CVarArg
    408             )
    409             archiveRequest.fetchLimit = 1
    410             if let chronicle = try? ctx.fetch(archiveRequest).first {
    411                 Archive.mirrorReadState(from: game, to: chronicle)
    412             }
    413         }
    414 
    415         // The remaining value fields are only adopted when the incoming record
    416         // is at least as new as what we have locally; otherwise a stale-but-
    417         // current server record (e.g. our own pending writes haven't landed yet)
    418         // would clobber the user's live selection on every fetch.
    419         let localUpdatedAt = entity.updatedAt
    420         let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true
    421         guard incomingIsFresher else { return }
    422         // An empty `name` is what older builds shipped from the selection publisher
    423         // before the fix; treat it as "no information" rather than letting it
    424         // clobber a previously-resolved name.
    425         if let renderedName, !renderedName.isEmpty {
    426             entity.name = renderedName
    427         }
    428         entity.updatedAt = updatedAt
    429         // A peer's published selection is remote-controlled; a coordinate
    430         // outside the grid is treated as no selection rather than persisted.
    431         if let selection = RecordSerializer.parsePlayerSelection(from: record),
    432            GridPosition(row: selection.row, col: selection.col).isPersistable(
    433                gridWidth: entity.game?.gridWidth ?? 0,
    434                gridHeight: entity.game?.gridHeight ?? 0
    435            ) {
    436             entity.selRow = NSNumber(value: Int64(selection.row))
    437             entity.selCol = NSNumber(value: Int64(selection.col))
    438             entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
    439         } else {
    440             entity.selRow = nil
    441             entity.selCol = nil
    442             entity.selDir = nil
    443         }
    444         // Adopt the record's push address. For a peer this is how the sender
    445         // learns where to address pushes; for our own record synced from a
    446         // sibling device, it's how the account's devices converge on one
    447         // per-game address (the LWW winner of this record).
    448         entity.pushAddress = RecordSerializer.parsePlayerPushAddress(from: record)
    449         let isRemoteAuthor = authorID != localAuthorID && authorID != CKCurrentUserDefaultName
    450         let hasSelection = entity.selRow != nil && entity.selCol != nil && entity.selDir != nil
    451         if isRemoteAuthor,
    452            (hadSelection || hasSelection),
    453            oldUpdatedAt != entity.updatedAt ||
    454                oldSelection.0 != entity.selRow ||
    455                oldSelection.1 != entity.selCol ||
    456                oldSelection.2 != entity.selDir {
    457             onPresenceChange(gameID)
    458         }
    459         if !foundExisting {
    460             onFirstTime(gameID)
    461         }
    462     }
    463 
    464     /// Merges every device's `MovesEntity` row for `gameID` and reconciles the
    465     /// `CellEntity` cache against the resulting grid. Must be called inside a
    466     /// `perform` block on the same context. Returns diagnostic messages
    467     /// for any fetch failure (normally empty) — the caller folds them into
    468     /// `BatchEffects.traces` so they reach the diagnostics log once the batch
    469     /// context unwinds.
    470     nonisolated func replayCellCache(
    471         for gameID: UUID,
    472         in ctx: NSManagedObjectContext
    473     ) -> [String] {
    474         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    475         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    476         gameReq.fetchLimit = 1
    477         let game: GameEntity?
    478         do {
    479             game = try ctx.fetch(gameReq).first
    480         } catch {
    481             // CKSyncEngine commits the batch when the delegate returns
    482             // (see fetchedRecordZoneChanges save-failure note), so re-throwing
    483             // won't redeliver — surface the failure instead of silently
    484             // leaving the cell cache stale for this game.
    485             return [Self.syncErrorMessage("replayCellCache game fetch", gameID: gameID, error: error)]
    486         }
    487         guard let game else { return [] }
    488 
    489         let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    490         movesReq.predicate = NSPredicate(format: "game == %@", game)
    491         let movesEntities: [MovesEntity]
    492         do {
    493             movesEntities = try ctx.fetch(movesReq)
    494         } catch {
    495             return [Self.syncErrorMessage("replayCellCache moves fetch", gameID: gameID, error: error)]
    496         }
    497         let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) }
    498         let gridState = GridStateMerger.merge(values)
    499 
    500         let existingCells = (game.cells as? Set<CellEntity>) ?? []
    501         var byPosition: [GridPosition: CellEntity] = [:]
    502         for cell in existingCells {
    503             byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
    504         }
    505 
    506         // Merged positions come from remote-controlled Moves payloads; the
    507         // codec already dropped Int16-unrepresentable entries, and this skips
    508         // anything outside the recorded grid so hostile coordinates never
    509         // materialize as CellEntity rows.
    510         var skippedOutOfGrid = 0
    511         for (pos, gridCell) in gridState {
    512             guard pos.isPersistable(gridWidth: game.gridWidth, gridHeight: game.gridHeight) else {
    513                 skippedOutOfGrid += 1
    514                 continue
    515             }
    516             let cell: CellEntity
    517             if let existing = byPosition[pos] {
    518                 cell = existing
    519             } else {
    520                 cell = CellEntity(context: ctx)
    521                 cell.game = game
    522                 cell.row = Int16(pos.row)
    523                 cell.col = Int16(pos.col)
    524             }
    525             cell.letter = gridCell.letter
    526             cell.markCode = gridCell.mark.code
    527             cell.letterAuthorID = gridCell.authorID
    528         }
    529 
    530         for (pos, cell) in byPosition where gridState[pos] == nil {
    531             cell.letter = ""
    532             cell.markCode = 0
    533             cell.letterAuthorID = nil
    534         }
    535         guard skippedOutOfGrid == 0 else {
    536             return [
    537                 "replayCellCache \(gameID.uuidString.prefix(8)): "
    538                     + "skipped \(skippedOutOfGrid) out-of-grid remote cell(s)"
    539             ]
    540         }
    541         return []
    542     }
    543 
    544     /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row
    545     /// is missing required fields (e.g. an unpopulated stub from a partial
    546     /// fetch).
    547     nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? {
    548         guard let gameID = entity.game?.id,
    549               let authorID = entity.authorID,
    550               let deviceID = entity.deviceID,
    551               let updatedAt = entity.updatedAt
    552         else { return nil }
    553         let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
    554         return MovesValue(
    555             gameID: gameID,
    556             authorID: authorID,
    557             deviceID: deviceID,
    558             cells: cells,
    559             updatedAt: updatedAt
    560         )
    561     }
    562 
    563     /// Formats a sync-context fetch/save failure for the diagnostics log. The
    564     /// engine's change token has already advanced by the time these helpers
    565     /// run inside the batch perform block, so the only available remediation is making
    566     /// the drop visible — and visible means traced (the on-device diagnostics
    567     /// log), not printed: console output never reaches a collected log.
    568     nonisolated static func syncErrorMessage(_ label: String, gameID: UUID, error: Error) -> String {
    569         let nsError = error as NSError
    570         return "\(label) FAILED for \(gameID.uuidString) " +
    571             "— domain=\(nsError.domain) code=\(nsError.code) " +
    572             "\(nsError.localizedDescription)"
    573     }
    574 
    575     /// Applies an inbound zone-wide `CKShare` for a game zone: hands the
    576     /// share's current accepted roster to `applyShareRoster` and returns the
    577     /// game ID when a previously accepted participant has disappeared — the
    578     /// signal that the game's push credentials must be rotated. Non-game zones
    579     /// (friend zones carry shares too) are ignored.
    580     nonisolated func applyShareRecord(
    581         _ share: CKShare,
    582         databaseScope: DatabaseScope,
    583         in ctx: NSManagedObjectContext
    584     ) -> UUID? {
    585         let zoneID = share.recordID.zoneID
    586         guard zoneID.zoneName.hasPrefix("game-"),
    587               let gameID = UUID(uuidString: String(zoneID.zoneName.dropFirst("game-".count)))
    588         else { return nil }
    589         // The share's participant list is CloudKit-maintained — participants
    590         // cannot forge it — so it is the authoritative membership. Only
    591         // accepted non-owner participants matter: they are the only people who
    592         // ever had zone access, and therefore the only ones who could hold the
    593         // game's credentials.
    594         let accepted = share.participants
    595             .filter { $0.role != .owner && $0.acceptanceStatus == .accepted }
    596             .compactMap { $0.userIdentity.userRecordID?.recordName }
    597         let departed = Self.applyShareRoster(
    598             gameID: gameID,
    599             zoneID: zoneID,
    600             acceptedParticipants: accepted,
    601             databaseScope: databaseScope,
    602             in: ctx
    603         )
    604         return departed ? gameID : nil
    605     }
    606 
    607     /// Records `acceptedParticipants` as the game's last-seen share roster and
    608     /// reports whether any member of the previously recorded roster is missing
    609     /// from it — someone left or was removed. The first sighting (no recorded
    610     /// roster yet) only seeds the baseline and never reports a departure, so
    611     /// updating the app doesn't trigger a spurious rotation on every shared
    612     /// game. Static and CloudKit-free so tests can drive it directly.
    613     nonisolated static func applyShareRoster(
    614         gameID: UUID,
    615         zoneID: CKRecordZone.ID,
    616         acceptedParticipants: [String],
    617         databaseScope: DatabaseScope,
    618         in ctx: NSManagedObjectContext
    619     ) -> Bool {
    620         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    621         req.predicate = RecordSerializer.gameIdentityPredicate(
    622             recordName: "game-\(gameID.uuidString)",
    623             zoneID: zoneID,
    624             databaseScope: databaseScope,
    625             entityPrefix: ""
    626         )
    627         req.fetchLimit = 1
    628         guard let entity = try? ctx.fetch(req).first else { return false }
    629 
    630         let current = Set(acceptedParticipants.filter { !$0.isEmpty })
    631         let encoded = current.sorted().joined(separator: ",")
    632         let priorEncoded = entity.shareParticipants
    633         if priorEncoded != encoded {
    634             entity.shareParticipants = encoded
    635         }
    636         guard let priorEncoded, !priorEncoded.isEmpty else { return false }
    637         let prior = Set(priorEncoded.split(separator: ",").map(String.init))
    638         return !prior.subtracting(current).isEmpty
    639     }
    640 
    641     /// Applies an inbound `Archive` record. Inert while a live (non-revoked)
    642     /// copy of the original game still exists on this device — the device already
    643     /// holds the data, so surfacing a second row would duplicate it. On a device
    644     /// without the original (fresh install / reinstall), it hydrates the snapshot
    645     /// into a standalone completed, owned game. Returns the materialized game id
    646     /// when a row was created, else `nil`.
    647     @discardableResult
    648     nonisolated func applyArchiveRecord(
    649         _ record: CKRecord,
    650         in ctx: NSManagedObjectContext,
    651         onDiagnostic: ((String) -> Void)? = nil
    652     ) -> UUID? {
    653         guard let payload = Archive.payload(from: record, onDiagnostic: onDiagnostic)
    654         else { return nil }
    655 
    656         let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    657         liveReq.predicate = NSPredicate(
    658             format: "id == %@ AND isAccessRevoked == NO",
    659             payload.originalGameID as CVarArg
    660         )
    661         liveReq.fetchLimit = 1
    662         if (try? ctx.fetch(liveReq).first) != nil { return nil }
    663 
    664         let created = Archive.materialize(payload, in: ctx)
    665         return created?.id
    666     }
    667 
    668     /// Applies a Chronicle explicitly selected by the completed-game pager.
    669     /// The compact record becomes the visible representation, while an
    670     /// existing live row is retained invisibly for acknowledgement and zone
    671     /// retirement bookkeeping.
    672     @discardableResult
    673     nonisolated func applyPreferredArchiveRecord(
    674         _ record: CKRecord,
    675         in ctx: NSManagedObjectContext,
    676         onDiagnostic: ((String) -> Void)? = nil
    677     ) -> UUID? {
    678         guard let payload = Archive.payload(from: record, onDiagnostic: onDiagnostic),
    679               let materialized = Archive.materialize(payload, in: ctx)
    680         else { return nil }
    681 
    682         let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    683         liveReq.predicate = NSPredicate(
    684             format: "id == %@ AND isAccessRevoked == NO",
    685             payload.originalGameID as CVarArg
    686         )
    687         liveReq.fetchLimit = 1
    688         if let live = try? ctx.fetch(liveReq).first {
    689             live.isSupersededByChronicle = true
    690         }
    691         return materialized.id
    692     }
    693 
    694     /// Callers gate game-scoped deletions through
    695     /// `RecordSerializer.isTrustedGameScopedDeletion` before invoking this.
    696     nonisolated func applyDeletion(
    697         recordID: CKRecord.ID,
    698         recordType: CKRecord.RecordType,
    699         databaseScope: DatabaseScope,
    700         in ctx: NSManagedObjectContext
    701     ) {
    702         let name = recordID.recordName
    703         let entityName: String
    704         if name.hasPrefix("moves-") {
    705             entityName = "MovesEntity"
    706         } else if name.hasPrefix("player-") {
    707             entityName = "PlayerEntity"
    708         } else if name.hasPrefix("game-") {
    709             entityName = "GameEntity"
    710         } else {
    711             switch recordType {
    712             case "Moves": entityName = "MovesEntity"
    713             case "Player": entityName = "PlayerEntity"
    714             case "Game": entityName = "GameEntity"
    715             default: return
    716             }
    717         }
    718         let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
    719         let entityPrefix = entityName == "GameEntity" ? "" : "game"
    720         req.predicate = RecordSerializer.gameIdentityPredicate(
    721             recordName: name,
    722             zoneID: recordID.zoneID,
    723             databaseScope: databaseScope,
    724             entityPrefix: entityPrefix
    725         )
    726         req.fetchLimit = 1
    727         if let obj = try? ctx.fetch(req).first {
    728             ctx.delete(obj)
    729         }
    730     }
    731 }