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 }