SyncEngine.swift (137980B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import SwiftUI 5 6 enum CompletionDurableRecordKind: String, Codable, Hashable, Sendable { 7 case game 8 case moves 9 case journal 10 } 11 12 enum PingDeliveryState: Sendable { 13 case queued 14 case sent 15 case failed 16 } 17 18 enum InviteDeliveryFailure: Equatable, Sendable { 19 case quotaExceeded 20 case other 21 22 var title: LocalizedStringResource { 23 "Invitation Not Sent" 24 } 25 26 var body: LocalizedStringResource { 27 switch self { 28 case .quotaExceeded: 29 "The invitation was not received. This could be because your friend's iCloud storage is full." 30 case .other: 31 "The invitation could not be delivered. Try again." 32 } 33 } 34 35 init(error: Error) { 36 if let pingError = error as? SyncEngine.PingOutboxError, 37 pingError.isQuotaExceeded { 38 self = .quotaExceeded 39 } else { 40 self = .other 41 } 42 } 43 } 44 45 struct PingDeliveryUpdate: Sendable { 46 let recordName: String 47 let gameID: UUID 48 let addressee: String 49 let state: PingDeliveryState 50 let rollbackParticipantOnFailure: Bool 51 let failure: InviteDeliveryFailure? 52 } 53 54 extension EnvironmentValues { 55 @Entry var syncEngine: SyncEngine? = nil 56 } 57 58 extension Notification.Name { 59 /// Posted by `SyncEngine` after applying fetched record zone changes that 60 /// touched a game's roster-relevant state — a Player record, a Game record, 61 /// a deletion, or a new contributor's first Moves row (see 62 /// `BatchEffects.rosterRelevant`). `userInfo["gameIDs"]` is a `Set<UUID>`. 63 /// `PlayerRoster` observes this to refresh in response to remote name / 64 /// cursor updates and new participants joining. Repeat Moves from a known 65 /// contributor (peer letters) are excluded — they don't change the roster. 66 static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh") 67 68 /// Posted by `SyncEngine` when an inbound `Journal` record lands for one or 69 /// more games. `userInfo["gameIDs"]` is a `Set<UUID>`. The finish-banner 70 /// replay observes this to re-check completeness the moment a contributor's 71 /// journal syncs, instead of polling. 72 static let replayJournalDidSync = Notification.Name("replayJournalDidSync") 73 74 /// The private Chronicle zone changed. Its assets are deliberately excluded 75 /// from CKSyncEngine and the Game List performs a bounded metadata query. 76 static let chronicleZoneDidChange = Notification.Name("chronicleZoneDidChange") 77 } 78 79 80 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for 81 /// the private database (owned games and shares) and one for the shared 82 /// database (joined games). Zone creation, subscription setup, change-token 83 /// management, batching, and retry are all delegated to the framework. 84 /// This actor's job is to: 85 /// 86 /// - Start and persist each engine's state across launches. 87 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone 88 /// changes that CKSyncEngine will batch and send. 89 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and 90 /// replay them onto the `CellEntity` cache. 91 /// - Notify the main actor so the in-memory `Game` stays current. 92 actor SyncEngine { 93 enum PingOutboxError: Error, Equatable, LocalizedError { 94 case syncEngineUnavailable 95 case deliveryFailed(code: Int?) 96 /// CloudKit did not confirm or reject the send within the caller's 97 /// patience window. The Ping stays durably queued and its CKShare seat 98 /// intact; this only unblocks the caller so the UI stops waiting. 99 case deliveryPending 100 101 var errorDescription: String? { 102 switch self { 103 case .syncEngineUnavailable: 104 return String(localized: "The invitation could not be queued for syncing.") 105 case .deliveryFailed(let code) 106 where code == CKError.quotaExceeded.rawValue: 107 return String(localized: InviteDeliveryFailure.quotaExceeded.body) 108 case .deliveryFailed: 109 return String(localized: InviteDeliveryFailure.other.body) 110 case .deliveryPending: 111 return String(localized: "The invitation is queued and will keep trying.") 112 } 113 } 114 115 var isQuotaExceeded: Bool { 116 if case .deliveryFailed(let code) = self { 117 return code == CKError.quotaExceeded.rawValue 118 } 119 return false 120 } 121 } 122 123 let container: CKContainer 124 let persistence: PersistenceController 125 126 var privateEngine: CKSyncEngine? 127 var sharedEngine: CKSyncEngine? 128 129 /// Dedicated serial context for the singleton `SyncStateEntity` row. Both 130 /// scopes' engine state persists through this one context: `await 131 /// ctx.perform` does not hold the actor the way `performAndWait` did, so 132 /// per-call fresh contexts could otherwise race each other — two 133 /// concurrent saves could each fetch-or-create the row (duplicating it on 134 /// first launch) or fail on a row-level conflict. Automatic merging keeps 135 /// the long-lived context coherent if the row is deleted externally (the 136 /// database reset path). 137 private lazy var syncStateContext: NSManagedObjectContext = { 138 let ctx = persistence.container.newBackgroundContext() 139 ctx.automaticallyMergesChangesFromParent = true 140 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 141 return ctx 142 }() 143 144 /// True while `start()` is between its idempotence guard and the engine 145 /// assignments. The body suspends (engine-state read, state decode), so 146 /// the `privateEngine == nil` guard alone no longer makes a concurrent 147 /// second call a no-op. 148 private var isStarting = false 149 150 /// In-memory mirror of the durable PendingPingEntity outbox, keyed by 151 /// record name. CKSyncEngine persists the pending record ID but not the 152 /// body supplied by its record provider, so the outbox is what makes a 153 /// queued invite reconstructable after process termination. 154 private var pendingPings: [String: PingPayload] = [:] 155 private var pingDeliveryWaiters: [ 156 String: CheckedContinuation<Void, Error> 157 ] = [:] 158 private var pingDeliveryTimeouts: [String: Task<Void, Never>] = [:] 159 private var scheduledPingRetries: Set<String> = [] 160 161 /// How long a `waitForServerConfirmation` send blocks before it stops 162 /// waiting and reports `.deliveryPending`. This is a UI-patience limit, not 163 /// a delivery deadline: the Ping remains queued and the durable outbox keeps 164 /// retrying, so a slow network no longer strands the caller on a spinner. 165 private var pingConfirmationTimeout: Duration = .seconds(30) 166 167 private lazy var pendingPingContext: NSManagedObjectContext = { 168 let ctx = persistence.container.newBackgroundContext() 169 ctx.automaticallyMergesChangesFromParent = true 170 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 171 return ctx 172 }() 173 /// Payloads for `Decision` records pending send, keyed by 174 /// `decisionStateKey` (zone + record name — the same decision record can 175 /// be pending in several zones at once, e.g. a name Decision fanned out 176 /// to every friend zone, and one zone's save must not strip the others'). 177 /// Unlike a ping body, these must survive an app kill: CKSyncEngine persists 178 /// the pending `.saveRecord`, so on relaunch `buildRecord` would otherwise 179 /// emit a payload-less Decision (e.g. an empty `pushSecret`) that uploads as 180 /// a poison record other devices can't parse and this device never 181 /// republishes. Mirrored to UserDefaults on every mutation and restored in 182 /// `start()`. 183 private var pendingDecisionPayloads: [String: String] = [:] 184 185 private static let pendingDecisionPayloadsDefaultsKey = 186 "SyncEngine.pendingDecisionPayloads" 187 188 /// Intended generation for a versioned `Decision` pending send, keyed by 189 /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and 190 /// durability: the version must survive an app kill so a rebuilt rotation 191 /// re-asserts at the right generation rather than a stale one. The push 192 /// secret, display name and block flag are versioned; unversioned decisions 193 /// (left/pushAddress) have no entry and stay write-once on conflict. 194 private var pendingDecisionVersions: [String: Int64] = [:] 195 196 private static let pendingDecisionVersionsDefaultsKey = 197 "SyncEngine.pendingDecisionVersions" 198 199 /// Server system fields adopted while recovering a *versioned* Decision that 200 /// lost the change-tag race but won on version — stashed so the next 201 /// `buildRecord` carries the server's tag and the overwrite is accepted 202 /// rather than re-colliding. In-memory only: the version in 203 /// `pendingDecisionVersions` carries correctness across a relaunch (a 204 /// tagless re-send simply re-hits the conflict and re-recovers), so this is 205 /// a round-trip optimization, not durable state. Keyed by 206 /// `decisionStateKey` — the same record name carries a different change 207 /// tag in each zone it lives in. Cleared once the record saves or settles. 208 private var decisionSystemFields: [String: Data] = [:] 209 210 /// Key for the per-zone decision state above. A decision's record name is 211 /// deterministic, so the same name can be pending in the account zone and 212 /// several friend zones simultaneously; the zone name disambiguates. 213 nonisolated static func decisionStateKey(_ recordID: CKRecord.ID) -> String { 214 "\(recordID.zoneID.zoneName)/\(recordID.recordName)" 215 } 216 217 struct PingPayload: Codable { 218 let gameID: UUID 219 let authorID: String 220 let deviceID: String 221 let playerName: String 222 let puzzleTitle: String 223 let eventTimestampMs: Int64 224 let kind: PingKind 225 let payload: String? 226 let addressee: String? 227 let zoneName: String 228 let zoneOwnerName: String 229 let databaseScope: DatabaseScope 230 /// Present only on game invitations whose CKShare seat was created by 231 /// this Ping. Optional so durable outbox rows written by older builds 232 /// continue to decode. 233 let rollbackParticipantOnFailure: Bool? 234 235 var recordZoneID: CKRecordZone.ID { 236 CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName) 237 } 238 } 239 240 /// Label for the in-flight fetch — surfaced in traces so the diagnostics 241 /// log can distinguish push-driven fetches from polls / foreground / etc. 242 /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler). 243 private var currentFetchSource: String? 244 /// Game zones whose root metadata says they completed before the initial 245 /// seven-day Game List window. CKSyncEngine must not eagerly hydrate these; 246 /// the metadata pager or the separate migration path fetches them directly. 247 private var privateCompletedFetchExclusions: Set<CKRecordZone.ID>? 248 private var sharedCompletedFetchExclusions: Set<CKRecordZone.ID>? 249 /// One-shot flag — set the first time we observe shared-DB content 250 /// arriving via a push-triggered fetch. Confirms the silent-push path is 251 /// actually wired up end-to-end. 252 private var loggedFirstSharedPushPayload = false 253 254 var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? 255 /// Fires with the game IDs for which a collaborator's `Player` record was 256 /// seen for the **first time** (a new `PlayerEntity` was created) — not on 257 /// their subsequent name / cursor updates. Independent of moves; the 258 /// friendship bootstrap keys off this so a collaborator becomes a friend 259 /// once, as soon as their identity syncs, without waiting for a move. 260 var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? 261 /// Fires when a remote collaborator's `Player` record updates active 262 /// presence state (selection set/cleared or refreshed). Unlike 263 /// `onRemotePlayersUpdated`, this fires for existing Player records too, 264 /// so live engagement can start when a known collaborator opens a puzzle. 265 var onRemotePlayerPresenceChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? 266 /// Fires with the game IDs whose Game-record `engagement` creds just 267 /// changed (a peer minted or rotated the room). Drives the receiver to 268 /// reconcile its live connection toward the new creds. 269 var onRemoteEngagementChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? 270 /// Fires with the game IDs whose zone-wide share just lost a previously 271 /// accepted participant. The handler rotates the game's push credentials 272 /// so the departed device's cached copy stops granting push access. 273 var onPushCredentialRotationNeeded: (@MainActor @Sendable (Set<UUID>) async -> Void)? 274 /// Fires with the game IDs whose inbound Game record changed the push 275 /// credential blob (a peer minted — or rotated — it). The handler re-runs 276 /// push registration so this device binds under the new credID promptly 277 /// and drops its old binding, instead of waiting for the next launch or 278 /// token refresh. 279 var onRemoteCredentialsChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? 280 var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? 281 private var onPingDeliveryUpdate: (@MainActor @Sendable (PingDeliveryUpdate) -> Void)? 282 private var onAccountChange: (@MainActor @Sendable () async -> Void)? 283 private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? 284 private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? 285 /// Fires when an inbound Game record transitions a local row to completed. 286 /// App-level side effects that are not sync-engine state (for example 287 /// closing public share tickets) hang off this edge. 288 var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)? 289 /// Fires when late contributor journals land so provisional Chronicles can 290 /// be rebuilt without waiting for the next cold-launch reconciliation. 291 private var onReplayJournalsSynced: 292 (@MainActor @Sendable (Set<UUID>) async -> Void)? 293 private var onCompletionRecordsSaved: (@MainActor @Sendable ([UUID: Set<CompletionDurableRecordKind>]) async -> Void)? 294 /// Fires with the game ID of a shared zone that just appeared locally — 295 /// the user joined the game here or on a sibling device. Drives cleanup 296 /// of the now-redundant pending invite row. 297 private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)? 298 /// Fires with Ping records that were just deleted on the server (a sibling 299 /// device consumed a directed ping). Drives cross-device withdrawal of the 300 /// notification and any local durable invite row this device may hold. 301 var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)? 302 /// Fires with (gameID, presenceUntil) pairs lifted from inbound Player records 303 /// whose authorID matches the local user. A sibling device has recorded 304 /// the account's read horizon; active sessions may move it into the near 305 /// future and later close it with a lower current-time value. 306 var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Date?)]) async -> Void)? 307 var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)? 308 var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)? 309 var onBlockedFriendsChanged: (@MainActor @Sendable (Set<String>) async -> Void)? 310 var onGameVisibilityCandidates: (@MainActor @Sendable (Set<UUID>) async -> Void)? 311 private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? 312 private var tracer: (@MainActor @Sendable (String) -> Void)? 313 /// Fires when a delegate event reports a successful round-trip with the 314 /// CloudKit server (fetched DB changes, fetched zone changes, or sent 315 /// zone changes with no failures). Bumps `Last Success` in the 316 /// diagnostics view so the timestamp reflects actual engine activity 317 /// rather than only instrumented phases run through `SyncMonitor.run`. 318 private var successCheckpoint: (@MainActor @Sendable () -> Void)? 319 var liveQueryCheckpoints: [String: Date] = [:] 320 let liveQueryCheckpointOverlap: TimeInterval = 5 321 322 /// Per-scope checkpoint for the background ping fast path. Independent of 323 /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are 324 /// per-zone and Moves/Player oriented). 325 var pingPushCheckpoints: [DatabaseScope: Date] = [:] 326 let pingPushCheckpointOverlap: TimeInterval = 30 327 /// Per-scope record-name → modificationDate of Ping records already 328 /// surfaced by the fast path. The time-window query deliberately re-fetches 329 /// anything within `pingPushCheckpointOverlap` of the floor (skew safety), 330 /// so without this a record — unboundedly, the newest one — would re-emit 331 /// on every push. Pruned to the overlap window each scan, so it stays 332 /// small. Mirrors the presentation-layer dedupe in `NotificationState`. 333 var seenPingRecords: [DatabaseScope: [String: Date]] = [:] 334 let backgroundSessionLookback: TimeInterval = 10 * 60 335 336 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { 337 tracer = t 338 } 339 340 func setSuccessCheckpoint(_ cb: @MainActor @Sendable @escaping () -> Void) { 341 successCheckpoint = cb 342 } 343 344 private func noteRoundTripSuccess() async { 345 guard let successCheckpoint else { return } 346 await successCheckpoint() 347 } 348 349 func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 350 onRemoteMovesUpdated = cb 351 } 352 353 func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 354 onRemotePlayersUpdated = cb 355 } 356 357 func setOnRemotePlayerPresenceChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 358 onRemotePlayerPresenceChanged = cb 359 } 360 361 func setOnRemoteEngagementChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 362 onRemoteEngagementChanged = cb 363 } 364 365 func setOnPushCredentialRotationNeeded(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 366 onPushCredentialRotationNeeded = cb 367 } 368 369 func setOnRemoteCredentialsChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 370 onRemoteCredentialsChanged = cb 371 } 372 373 func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) { 374 onPings = cb 375 } 376 377 func setOnPingDeliveryUpdate( 378 _ cb: @MainActor @Sendable @escaping (PingDeliveryUpdate) -> Void 379 ) { 380 onPingDeliveryUpdate = cb 381 } 382 383 func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) { 384 onAccountChange = cb 385 } 386 387 func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 388 onGameAccessRevoked = cb 389 } 390 391 func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 392 onGameRemoved = cb 393 } 394 395 func setOnGameCompleted(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 396 onGameCompleted = cb 397 } 398 399 func setOnReplayJournalsSynced( 400 _ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void 401 ) { 402 onReplayJournalsSynced = cb 403 } 404 405 func setOnCompletionRecordsSaved( 406 _ cb: @MainActor @Sendable @escaping ([UUID: Set<CompletionDurableRecordKind>]) async -> Void 407 ) { 408 onCompletionRecordsSaved = cb 409 } 410 411 func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 412 onGameJoined = cb 413 } 414 415 func setOnPingDeleted( 416 _ cb: @MainActor @Sendable @escaping ([(recordName: String, gameID: UUID)]) async -> Void 417 ) { 418 onPingDeleted = cb 419 } 420 421 func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Date?)]) async -> Void) { 422 onIncomingReadCursor = cb 423 } 424 425 func setOnAccountPushAddress(_ cb: @MainActor @Sendable @escaping (String) async -> Void) { 426 onAccountPushAddress = cb 427 } 428 429 func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String, Int64) async -> Void) { 430 onAccountPushSecret = cb 431 } 432 433 func setOnBlockedFriendsChanged(_ cb: @MainActor @Sendable @escaping (Set<String>) async -> Void) { 434 onBlockedFriendsChanged = cb 435 } 436 437 func setOnGameVisibilityCandidates(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 438 onGameVisibilityCandidates = cb 439 } 440 441 func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) { 442 localAuthorIDProvider = cb 443 } 444 445 init(container: CKContainer, persistence: PersistenceController) { 446 self.container = container 447 self.persistence = persistence 448 } 449 450 // MARK: - Lifecycle 451 452 /// Creates both `CKSyncEngine` instances, restoring previously-saved state 453 /// so pending changes and change tokens survive restarts. Call once after 454 /// wiring callbacks. Idempotent — extra calls are no-ops so a race between 455 /// `services.start()` and a scene-active foreground sync can't double- 456 /// initialise the engines or re-fire subscription setup. 457 func start() async { 458 guard privateEngine == nil, sharedEngine == nil, !isStarting else { return } 459 isStarting = true 460 defer { isStarting = false } 461 462 // CKSyncEngine restores its pending `.saveRecord` changes from the 463 // serialized state below; restore the matching Ping/Decision payloads 464 // so pending records rebuild with their bodies. 465 await restorePendingPings() 466 restorePendingDecisionPayloads() 467 restorePendingDecisionVersions() 468 469 let ctx = syncStateContext 470 let (privateData, sharedData): (Data?, Data?) = await ctx.perform { 471 let entity = SyncStateEntity.current(in: ctx) 472 return (entity.ckPrivateEngineState, entity.ckSharedEngineState) 473 } 474 475 let privateState = await decodeEngineState(privateData, label: "private") 476 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 477 database: container.privateCloudDatabase, 478 stateSerialization: privateState, 479 delegate: self 480 )) 481 482 let sharedState = await decodeEngineState(sharedData, label: "shared") 483 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 484 database: container.sharedCloudDatabase, 485 stateSerialization: sharedState, 486 delegate: self 487 )) 488 489 // The outbox is authoritative. A process can terminate after its row 490 // commits but before CKSyncEngine serializes the matching pending ID, 491 // so re-add every durable Ping on startup. Record IDs are stable and 492 // CKSyncEngine deduplicates repeated pending saves. 493 reconcilePendingPingOutbox() 494 } 495 496 /// Kicks CKSyncEngine's outbound drain after pending state has been queued. 497 /// This must be detached: several enqueue paths are reachable from 498 /// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the 499 /// callback's executor and re-enter CKSyncEngine before the callback 500 /// unwinds, tripping CloudKit's serialization guard. Keep one detached 501 /// send in flight per scope so repeated enqueues for the same record 502 /// coalesce instead of racing separate drains against adjacent etags. 503 private func sendChangesDetached(on engine: CKSyncEngine) { 504 let scope = scope(for: engine) 505 if sendChangesInFlight.contains(scope) { 506 sendChangesPending.insert(scope) 507 return 508 } 509 sendChangesInFlight.insert(scope) 510 Task.detached { [engine] in 511 try? await engine.sendChanges() 512 await self.finishDetachedSendChanges(for: scope) 513 } 514 } 515 516 /// The database an engine is bound to. Read off the engine itself rather 517 /// than compared against `privateEngine`/`sharedEngine`: `resetSyncState` 518 /// (and the account-switch / v4-migration purges that call it) replaces 519 /// both instances while their fetches are still in flight, and a retired 520 /// engine matches neither property. Identity comparison then misreports 521 /// the retired engine's scope — for `handleEvent` it silently resolved 522 /// *private* events to shared, which minted "Joining…" placeholders for 523 /// the user's own zones, cross-wrote engine state between scopes, and 524 /// applied private zone deletions as share revocations. The binding is 525 /// immutable for an engine's lifetime, so it stays correct after a swap. 526 private nonisolated func scope(for engine: CKSyncEngine) -> DatabaseScope { 527 DatabaseScope(isPrivate: engine.database.databaseScope == .private) 528 } 529 530 private func finishDetachedSendChanges(for scope: DatabaseScope) { 531 sendChangesInFlight.remove(scope) 532 guard sendChangesPending.remove(scope) != nil else { return } 533 let engine = scope == .shared ? sharedEngine : privateEngine 534 guard let engine else { return } 535 sendChangesDetached(on: engine) 536 } 537 538 /// Per-scope burst depth. `enqueuePlayer` consults this — while 539 /// non-zero for a scope, it records that a drain is owed instead of 540 /// firing `sendChanges` immediately. The outermost frame issues one 541 /// `sendChanges` on exit if any enqueue landed during the burst. 542 /// Replaces an earlier time-window coalesce that had to be tuned to 543 /// match `PlayerSelectionPublisher`'s debounce: the open path now 544 /// explicitly fans out read cursor, name, and the initial selection 545 /// inside a burst, so the batch is bounded by the work that produces 546 /// it rather than by a wall-clock window. 547 private var playerSendBurstDepth: [DatabaseScope: Int] = [:] 548 private var playerSendBurstPending: Set<DatabaseScope> = [] 549 private var sendChangesInFlight: Set<DatabaseScope> = [] 550 private var sendChangesPending: Set<DatabaseScope> = [] 551 552 /// Opens a Player-record send burst for `gameID`'s scope. Subsequent 553 /// `enqueuePlayer` calls on that scope skip their immediate 554 /// drain; the caller must pair this with `endPlayerSendBurst(scope:)`, 555 /// which fires one `sendChanges` if any enqueue landed in between. 556 /// Returns the scope so the caller can close the matching burst even 557 /// if the game's zone routing changes after the call. 558 func beginPlayerSendBurst(gameID: UUID) -> DatabaseScope? { 559 let ctx = persistence.container.newBackgroundContext() 560 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil } 561 playerSendBurstDepth[info.scope, default: 0] += 1 562 return info.scope 563 } 564 565 /// Closes a burst opened by `beginPlayerSendBurst`. The outermost 566 /// frame drains the affected engine if any enqueue landed during the 567 /// burst; nested frames just decrement the counter. 568 func endPlayerSendBurst(scope: DatabaseScope) { 569 guard let depth = playerSendBurstDepth[scope], depth > 0 else { return } 570 if depth > 1 { 571 playerSendBurstDepth[scope] = depth - 1 572 return 573 } 574 playerSendBurstDepth.removeValue(forKey: scope) 575 guard playerSendBurstPending.remove(scope) != nil else { return } 576 let engine = scope == .shared ? sharedEngine : privateEngine 577 guard let engine else { return } 578 sendChangesDetached(on: engine) 579 } 580 581 // MARK: - Outbound 582 583 /// Registers each game's local-device Moves record as a pending save, 584 /// routed per-game to the correct engine. Called by the `MovesUpdater` 585 /// sink after the device's `MovesEntity` row has been merged and persisted. 586 /// 587 /// `drain` controls whether to force an eager `sendChanges()`: 588 /// 589 /// - `false` (live typing debounce): the engagement socket already carries 590 /// the letters via `cellEdit`/`cellEditBatch`, so CloudKit is the durable 591 /// backstop ("appears on sync"), not the live path. With no peer watching 592 /// the durable write in real time, leaving the drain to CKSyncEngine's 593 /// automatic scheduler lets it coalesce a typing burst into far fewer 594 /// round-trips and avoids the self-induced oplock races two concurrent 595 /// drains produced over the same `CKRecord.ID`. 596 /// - `true` (explicit `flush()` on leave/background, and recovery via 597 /// `enqueueUnconfirmedMoves`): the solver's final letters must reach 598 /// CloudKit promptly even when no peer is on the socket, so force the 599 /// send rather than waiting for the next foreground. 600 func enqueueMoves(gameIDs: Set<UUID>, drain: Bool = true) async { 601 guard !gameIDs.isEmpty else { return } 602 // The local-device row is matched by author as well as device: after 603 // an iCloud account switch this device's old rows persist under the 604 // previous authorID (the record name embeds the author, so they are 605 // distinct rows), and a device-only `fetchLimit = 1` lookup could pick 606 // the old account's record and push a save the user no longer owns. 607 // When no author is known yet (provider unset, first launch) fall back 608 // to the device-only match, which is unambiguous until a switch has 609 // happened. 610 let localAuthorID = await currentLocalAuthorID() 611 let ctx = persistence.container.newBackgroundContext() 612 let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = await ctx.perform { 613 var privateIDs: [CKRecord.ID] = [] 614 var sharedIDs: [CKRecord.ID] = [] 615 for gameID in gameIDs { 616 guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { continue } 617 let isShared = info.scope == .shared 618 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 619 if let localAuthorID, !localAuthorID.isEmpty { 620 req.predicate = NSPredicate( 621 format: "game.id == %@ AND deviceID == %@ AND authorID == %@", 622 gameID as CVarArg, 623 RecordSerializer.localDeviceID, 624 localAuthorID 625 ) 626 } else { 627 req.predicate = NSPredicate( 628 format: "game.id == %@ AND deviceID == %@", 629 gameID as CVarArg, 630 RecordSerializer.localDeviceID 631 ) 632 } 633 req.fetchLimit = 1 634 guard let entity = try? ctx.fetch(req).first, 635 let recordName = entity.ckRecordName 636 else { continue } 637 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 638 if isShared { 639 sharedIDs.append(recordID) 640 } else { 641 privateIDs.append(recordID) 642 } 643 } 644 return (privateIDs, sharedIDs) 645 } 646 if !privateRecordIDs.isEmpty, let engine = privateEngine { 647 engine.state.add( 648 pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) } 649 ) 650 if drain { sendChangesDetached(on: engine) } 651 } 652 if !sharedRecordIDs.isEmpty, let engine = sharedEngine { 653 engine.state.add( 654 pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) } 655 ) 656 if drain { sendChangesDetached(on: engine) } 657 } 658 } 659 660 /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit 661 /// system fields. Covers crash/relaunch recovery and any save that reached 662 /// Core Data before CKSyncEngine recorded the pending change. Scoped to the 663 /// current author for the same reason as `enqueueMoves`: an account 664 /// switch leaves the old account's unconfirmed rows behind on this device, 665 /// and recovery must not push them under the new account. 666 @discardableResult 667 func enqueueUnconfirmedMoves() async -> Int { 668 let localAuthorID = await currentLocalAuthorID() 669 let ctx = persistence.container.newBackgroundContext() 670 let gameIDs: Set<UUID> = await ctx.perform { 671 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 672 if let localAuthorID, !localAuthorID.isEmpty { 673 req.predicate = NSPredicate( 674 format: "ckSystemFields == nil AND deviceID == %@ AND authorID == %@", 675 RecordSerializer.localDeviceID, 676 localAuthorID 677 ) 678 } else { 679 req.predicate = NSPredicate( 680 format: "ckSystemFields == nil AND deviceID == %@", 681 RecordSerializer.localDeviceID 682 ) 683 } 684 let entities = (try? ctx.fetch(req)) ?? [] 685 return Set(entities.compactMap { $0.game?.id }) 686 } 687 await enqueueMoves(gameIDs: gameIDs) 688 return gameIDs.count 689 } 690 691 /// Registers record deletions as pending sends. Extracts the game UUID 692 /// from the record name and routes to the correct engine. 693 /// Registers the game's CloudKit zone for deletion. Each game owns its 694 /// own zone, so this removes all remote records for the puzzle, including 695 /// moves, player records, pings, and share metadata. 696 func enqueueDeleteGame(_ deletion: GameCloudDeletion) { 697 if deletion.deletesLiveZone { 698 let zoneID = CKRecordZone.ID( 699 zoneName: deletion.ckZoneName, 700 ownerName: deletion.ckZoneOwnerName 701 ) 702 let engine = deletion.databaseScope == .shared ? sharedEngine : privateEngine 703 if let engine { 704 engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) 705 sendChangesDetached(on: engine) 706 } 707 } 708 709 guard let privateEngine else { return } 710 if let archiveRecordName = deletion.archiveRecordName { 711 let recordID = CKRecord.ID( 712 recordName: archiveRecordName, 713 zoneID: Archive.zoneID 714 ) 715 privateEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 716 } 717 if let legacyZoneName = deletion.legacyArchiveZoneName { 718 let legacyZoneID = CKRecordZone.ID( 719 zoneName: legacyZoneName, 720 ownerName: CKCurrentUserDefaultName 721 ) 722 privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(legacyZoneID)]) 723 } 724 sendChangesDetached(on: privateEngine) 725 } 726 727 /// Retires an owned completed game's live zone after its archive policy has 728 /// been satisfied. Unlike user deletion, this deliberately leaves the 729 /// account's Archive record intact. 730 func enqueueRetireOwnedGameZone(_ zoneID: CKRecordZone.ID) { 731 guard let privateEngine else { return } 732 privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) 733 sendChangesDetached(on: privateEngine) 734 } 735 736 /// Removes a migrated legacy per-game archive zone after the compact record 737 /// has saved in the common archive zone. 738 func enqueueDeleteLegacyArchiveZone(_ zoneID: CKRecordZone.ID) { 739 guard let privateEngine else { return } 740 privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) 741 sendChangesDetached(on: privateEngine) 742 } 743 744 private func storePendingPing( 745 _ ping: PingPayload, 746 recordName: String 747 ) async throws { 748 let data = try JSONEncoder().encode(ping) 749 let ctx = pendingPingContext 750 try await ctx.perform { 751 let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") 752 req.predicate = NSPredicate(format: "recordName == %@", recordName) 753 req.fetchLimit = 1 754 let entity = try ctx.fetch(req).first ?? PendingPingEntity(context: ctx) 755 entity.recordName = recordName 756 entity.zoneName = ping.zoneName 757 entity.zoneOwnerName = ping.zoneOwnerName 758 entity.databaseScope = ping.databaseScope.rawValue 759 entity.payloadData = data 760 try ctx.save() 761 } 762 pendingPings[recordName] = ping 763 await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping) 764 } 765 766 private func removePendingPing(recordName: String) async { 767 pendingPings.removeValue(forKey: recordName) 768 let ctx = pendingPingContext 769 await ctx.perform { 770 let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") 771 req.predicate = NSPredicate(format: "recordName == %@", recordName) 772 for entity in (try? ctx.fetch(req)) ?? [] { 773 ctx.delete(entity) 774 } 775 if ctx.hasChanges { try? ctx.save() } 776 } 777 } 778 779 private func confirmPendingPing(recordName: String) async { 780 scheduledPingRetries.remove(recordName) 781 if let ping = pendingPings[recordName] { 782 await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping) 783 } 784 await removePendingPing(recordName: recordName) 785 clearPingDeliveryTimeout(recordName: recordName) 786 pingDeliveryWaiters.removeValue(forKey: recordName)?.resume() 787 } 788 789 private func failPendingPing(recordName: String, error: NSError? = nil) async { 790 scheduledPingRetries.remove(recordName) 791 if let ping = pendingPings[recordName] { 792 let failure: InviteDeliveryFailure = error?.code 793 == CKError.quotaExceeded.rawValue ? .quotaExceeded : .other 794 await publishPingDeliveryUpdate( 795 .failed, 796 recordName: recordName, 797 ping: ping, 798 failure: failure 799 ) 800 } 801 await removePendingPing(recordName: recordName) 802 clearPingDeliveryTimeout(recordName: recordName) 803 pingDeliveryWaiters.removeValue(forKey: recordName)? 804 .resume(throwing: PingOutboxError.deliveryFailed(code: error?.code)) 805 } 806 807 private func waitForPingDelivery(recordName: String) async throws { 808 try await withTaskCancellationHandler { 809 try await withCheckedThrowingContinuation { continuation in 810 pingDeliveryWaiters[recordName] = continuation 811 armPingDeliveryTimeout(recordName: recordName) 812 } 813 } onCancel: { 814 Task { await self.cancelPingDeliveryWait(recordName: recordName) } 815 } 816 } 817 818 private func cancelPingDeliveryWait(recordName: String) { 819 clearPingDeliveryTimeout(recordName: recordName) 820 pingDeliveryWaiters.removeValue(forKey: recordName)? 821 .resume(throwing: CancellationError()) 822 } 823 824 /// Bounds a `waitForServerConfirmation` send so it never blocks forever. 825 /// On expiry the Ping is left queued (the durable outbox keeps retrying and 826 /// `confirmPendingPing`/`failPendingPing` still resolves its true outcome 827 /// later); only the caller's wait ends, with `.deliveryPending`. 828 private func armPingDeliveryTimeout(recordName: String) { 829 pingDeliveryTimeouts[recordName]?.cancel() 830 pingDeliveryTimeouts[recordName] = Task { [self] in 831 try? await Task.sleep(for: pingConfirmationTimeout) 832 guard !Task.isCancelled else { return } 833 timeoutPingDeliveryWait(recordName: recordName) 834 } 835 } 836 837 private func clearPingDeliveryTimeout(recordName: String) { 838 pingDeliveryTimeouts.removeValue(forKey: recordName)?.cancel() 839 } 840 841 private func timeoutPingDeliveryWait(recordName: String) { 842 pingDeliveryTimeouts.removeValue(forKey: recordName) 843 pingDeliveryWaiters.removeValue(forKey: recordName)? 844 .resume(throwing: PingOutboxError.deliveryPending) 845 } 846 847 private func restorePendingPings() async { 848 let ctx = pendingPingContext 849 pendingPings = await ctx.perform { 850 let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") 851 let rows = (try? ctx.fetch(req)) ?? [] 852 var restored: [String: PingPayload] = [:] 853 for row in rows { 854 guard let name = row.recordName, 855 let data = row.payloadData, 856 let ping = try? JSONDecoder().decode(PingPayload.self, from: data) 857 else { 858 ctx.delete(row) 859 continue 860 } 861 restored[name] = ping 862 } 863 if ctx.hasChanges { try? ctx.save() } 864 return restored 865 } 866 for (recordName, ping) in pendingPings { 867 await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping) 868 } 869 } 870 871 private func publishPingDeliveryUpdate( 872 _ state: PingDeliveryState, 873 recordName: String, 874 ping: PingPayload, 875 failure: InviteDeliveryFailure? = nil 876 ) async { 877 guard ping.kind == .invite, 878 let addressee = ping.addressee, 879 !addressee.isEmpty, 880 let onPingDeliveryUpdate 881 else { return } 882 await onPingDeliveryUpdate(PingDeliveryUpdate( 883 recordName: recordName, 884 gameID: ping.gameID, 885 addressee: addressee, 886 state: state, 887 rollbackParticipantOnFailure: ping.rollbackParticipantOnFailure == true, 888 failure: failure 889 )) 890 } 891 892 private func reconcilePendingPingOutbox() { 893 for (recordName, ping) in pendingPings { 894 let engine = ping.databaseScope == .shared ? sharedEngine : privateEngine 895 let recordID = CKRecord.ID(recordName: recordName, zoneID: ping.recordZoneID) 896 engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 897 } 898 } 899 900 func pendingInvitationPingCount() -> Int { 901 pendingPings.values.count { $0.kind == PingKind.invite } 902 } 903 904 /// Registers a Ping record as a pending send. Current clients only write 905 /// bootstrap kinds (`.friend` / `.invite` / `.decline`) — the 906 /// user-facing play events go through the push worker. Sender-only 907 /// state: the payload is committed to a durable outbox before the 908 /// CKSyncEngine save is queued, then retained until CloudKit confirms it. 909 @discardableResult 910 func enqueuePing( 911 kind: PingKind, 912 gameID: UUID, 913 authorID: String, 914 playerName: String, 915 payload: String? = nil, 916 addressee: String? = nil 917 ) async -> Bool { 918 let ctx = persistence.container.newBackgroundContext() 919 let zoneAndTitle: (info: ZoneInfo, title: String)? = await ctx.perform { 920 guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil } 921 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 922 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 923 req.fetchLimit = 1 924 let entity = try? ctx.fetch(req).first 925 let title = PuzzleNotificationText.title(for: entity) 926 return (info, title) 927 } 928 guard let zoneAndTitle else { 929 await trace( 930 "ping send: SKIPPED kind=\(kind.rawValue) " + 931 "game=\(gameID.uuidString) " + 932 "— no zone info (game not yet synced/shared on this device)" 933 ) 934 return false 935 } 936 let engine = zoneAndTitle.info.scope == .shared ? sharedEngine : privateEngine 937 guard let engine else { 938 await trace( 939 "ping send: SKIPPED kind=\(kind.rawValue) " + 940 "game=\(gameID.uuidString) " + 941 "— no CKSyncEngine for " + 942 "\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope" 943 ) 944 return false 945 } 946 let deviceID = RecordSerializer.localDeviceID 947 let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) 948 let recordName = RecordSerializer.recordName( 949 forPingInGame: gameID, 950 authorID: authorID, 951 deviceID: deviceID, 952 eventTimestampMs: eventTimestampMs 953 ) 954 let ping = PingPayload( 955 gameID: gameID, 956 authorID: authorID, 957 deviceID: deviceID, 958 playerName: playerName, 959 puzzleTitle: zoneAndTitle.title, 960 eventTimestampMs: eventTimestampMs, 961 kind: kind, 962 payload: payload, 963 addressee: addressee, 964 zoneName: zoneAndTitle.info.zoneID.zoneName, 965 zoneOwnerName: zoneAndTitle.info.zoneID.ownerName, 966 databaseScope: zoneAndTitle.info.scope, 967 rollbackParticipantOnFailure: nil 968 ) 969 do { 970 try await storePendingPing(ping, recordName: recordName) 971 } catch { 972 await trace("ping send: failed to persist outbox record \(recordName) — \(error)") 973 return false 974 } 975 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) 976 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 977 await trace( 978 "ping send: enqueued kind=\(kind.rawValue) " + 979 "game=\(gameID.uuidString) " + 980 "target=\(zoneAndTitle.info.scope == .shared ? "shared" : "private") " + 981 "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" 982 ) 983 sendChangesDetached(on: engine) 984 return true 985 } 986 987 988 /// Registers a directed Ping (`.invite` or `.decline`) into an existing 989 /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone 990 /// (not the game zone), so the zone and engine are passed in explicitly: 991 /// `.shared` means we joined the friend zone (it lives in our shared DB 992 /// → shared engine); `.private` means we own it (private DB → private 993 /// engine). The zone already exists by the time an invite or decline is 994 /// possible, so no `saveZone`. `gameID` is the game in question; it rides 995 /// the record name so the recipient resolves it without reading the game 996 /// zone. `authorID` is the sender, so an invite carries the inviter and a 997 /// decline carries the decliner. 998 func enqueueFriendZonePing( 999 kind: PingKind, 1000 gameID: UUID, 1001 gameTitle: String, 1002 authorID: String, 1003 playerName: String, 1004 addressee: String, 1005 friendZoneID: CKRecordZone.ID, 1006 friendZoneScope: DatabaseScope, 1007 payload: String? = nil, 1008 rollbackParticipantOnFailure: Bool = false, 1009 waitForServerConfirmation: Bool = false 1010 ) async throws { 1011 let engine = friendZoneScope == .shared ? sharedEngine : privateEngine 1012 guard let engine else { 1013 throw PingOutboxError.syncEngineUnavailable 1014 } 1015 let deviceID = RecordSerializer.localDeviceID 1016 let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) 1017 let recordName = RecordSerializer.recordName( 1018 forPingInGame: gameID, 1019 authorID: authorID, 1020 deviceID: deviceID, 1021 eventTimestampMs: eventTimestampMs 1022 ) 1023 let ping = PingPayload( 1024 gameID: gameID, 1025 authorID: authorID, 1026 deviceID: deviceID, 1027 playerName: playerName, 1028 puzzleTitle: gameTitle, 1029 eventTimestampMs: eventTimestampMs, 1030 kind: kind, 1031 payload: payload, 1032 addressee: addressee, 1033 zoneName: friendZoneID.zoneName, 1034 zoneOwnerName: friendZoneID.ownerName, 1035 databaseScope: friendZoneScope, 1036 rollbackParticipantOnFailure: rollbackParticipantOnFailure 1037 ) 1038 try await storePendingPing(ping, recordName: recordName) 1039 let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) 1040 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1041 sendChangesDetached(on: engine) 1042 if waitForServerConfirmation { 1043 try await waitForPingDelivery(recordName: recordName) 1044 } 1045 } 1046 1047 /// Registers a durable `Decision` record into the account zone so the fact 1048 /// reaches the user's own other devices. Decisions without payloads are 1049 /// reconstructable from their names; payload-bearing decisions mirror their 1050 /// body to UserDefaults so CKSyncEngine's persisted pending save survives an 1051 /// app kill without rebuilding as a payload-less record. Idempotent — a 1052 /// re-send of an existing decision is a benign conflict the send path drops. 1053 func enqueueDecision( 1054 kind: String, 1055 key: String, 1056 payload: String? = nil, 1057 version: Int64? = nil 1058 ) { 1059 guard let engine = privateEngine else { return } 1060 let zoneID = RecordSerializer.accountZoneID 1061 // CKSyncEngine dedupes redundant saveZone requests, so it's safe to 1062 // repeat — block may be the first thing ever written to this zone. 1063 engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))]) 1064 registerDecisionSave( 1065 kind: kind, key: key, payload: payload, version: version, 1066 zoneID: zoneID, engine: engine 1067 ) 1068 } 1069 1070 /// Registers a `Decision` into an existing *friend* zone — the channel a 1071 /// display name rides to reach the other participant. Engine selection 1072 /// mirrors `enqueueFriendInvitePing`: `.shared` means we joined the 1073 /// zone (shared engine), `.private` means we own it (private engine). 1074 /// The zone already exists by the time a friendship is recorded, so no 1075 /// `saveZone`. 1076 /// Returns whether the decision was actually enqueued. It is dropped when 1077 /// the target engine isn't up yet; callers that gate on a "published" 1078 /// marker must not record success on a drop, or the write never re-heals. 1079 @discardableResult 1080 func enqueueFriendDecision( 1081 kind: String, 1082 key: String, 1083 payload: String? = nil, 1084 version: Int64? = nil, 1085 friendZoneID: CKRecordZone.ID, 1086 friendZoneScope: DatabaseScope 1087 ) -> Bool { 1088 guard let engine = friendZoneScope == .shared ? sharedEngine : privateEngine else { return false } 1089 registerDecisionSave( 1090 kind: kind, key: key, payload: payload, version: version, 1091 zoneID: friendZoneID, engine: engine 1092 ) 1093 return true 1094 } 1095 1096 /// Routes a display-name Decision to its zone. The account zone may not 1097 /// exist yet, so that path rides `enqueueDecision`'s `saveZone` backstop; 1098 /// a friend zone always exists by the time a friendship is recorded. 1099 func enqueueNameDecision( 1100 authorID: String, 1101 name: String, 1102 version: Int64, 1103 zoneID: CKRecordZone.ID, 1104 scope: DatabaseScope 1105 ) { 1106 if zoneID == RecordSerializer.accountZoneID { 1107 enqueueDecision( 1108 kind: RecordSerializer.nameDecisionKind, 1109 key: authorID, 1110 payload: name, 1111 version: version 1112 ) 1113 } else { 1114 enqueueFriendDecision( 1115 kind: RecordSerializer.nameDecisionKind, 1116 key: authorID, 1117 payload: name, 1118 version: version, 1119 friendZoneID: zoneID, 1120 friendZoneScope: scope 1121 ) 1122 } 1123 } 1124 1125 private func registerDecisionSave( 1126 kind: String, 1127 key: String, 1128 payload: String?, 1129 version: Int64?, 1130 zoneID: CKRecordZone.ID, 1131 engine: CKSyncEngine 1132 ) { 1133 let name = RecordSerializer.decisionRecordName(kind: kind, key: key) 1134 let recordID = CKRecord.ID(recordName: name, zoneID: zoneID) 1135 let stateKey = Self.decisionStateKey(recordID) 1136 if let payload { 1137 pendingDecisionPayloads[stateKey] = payload 1138 persistPendingDecisionPayloads() 1139 } 1140 if let version { 1141 pendingDecisionVersions[stateKey] = version 1142 persistPendingDecisionVersions() 1143 } 1144 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1145 sendChangesDetached(on: engine) 1146 } 1147 1148 /// Deletes a durable `Decision` record (account zone) so a fact that no 1149 /// longer holds stops propagating — e.g. a `left` decision is voided when 1150 /// the user re-joins that game. Deleting an absent record is benign 1151 /// (CloudKit reports it gone; the send path does not retry-loop it). 1152 func enqueueDecisionDeletion(kind: String, key: String) { 1153 guard let engine = privateEngine else { return } 1154 let name = RecordSerializer.decisionRecordName(kind: kind, key: key) 1155 let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID) 1156 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1157 sendChangesDetached(on: engine) 1158 } 1159 1160 /// Mirrors `pendingDecisionPayloads` to durable storage. CKSyncEngine 1161 /// persists the pending `.saveRecord` on its own, so the payload must be 1162 /// equally durable or a relaunch sends a payload-less Decision. 1163 private func persistPendingDecisionPayloads() { 1164 if pendingDecisionPayloads.isEmpty { 1165 UserDefaults.standard.removeObject( 1166 forKey: Self.pendingDecisionPayloadsDefaultsKey 1167 ) 1168 } else { 1169 UserDefaults.standard.set( 1170 pendingDecisionPayloads, 1171 forKey: Self.pendingDecisionPayloadsDefaultsKey 1172 ) 1173 } 1174 } 1175 1176 private func restorePendingDecisionPayloads() { 1177 pendingDecisionPayloads = (UserDefaults.standard.dictionary( 1178 forKey: Self.pendingDecisionPayloadsDefaultsKey 1179 ) as? [String: String]) ?? [:] 1180 } 1181 1182 /// Mirrors `pendingDecisionVersions` to durable storage, alongside 1183 /// `persistPendingDecisionPayloads`. NSNumber/Int64 is plist-representable. 1184 private func persistPendingDecisionVersions() { 1185 if pendingDecisionVersions.isEmpty { 1186 UserDefaults.standard.removeObject( 1187 forKey: Self.pendingDecisionVersionsDefaultsKey 1188 ) 1189 } else { 1190 UserDefaults.standard.set( 1191 pendingDecisionVersions.mapValues { NSNumber(value: $0) }, 1192 forKey: Self.pendingDecisionVersionsDefaultsKey 1193 ) 1194 } 1195 } 1196 1197 private func restorePendingDecisionVersions() { 1198 let raw = (UserDefaults.standard.dictionary( 1199 forKey: Self.pendingDecisionVersionsDefaultsKey 1200 ) as? [String: NSNumber]) ?? [:] 1201 pendingDecisionVersions = raw.mapValues { $0.int64Value } 1202 } 1203 1204 /// Consume-deletes a single Ping the local account has handled (shown, 1205 /// suppressed, or a duplicate). The deletion syncs through the game zone so 1206 /// this user's other devices withdraw any notification they showed for it. 1207 /// Deleting an absent record is benign (CloudKit reports it gone; the send 1208 /// path does not retry-loop it). The deletion is queued into `engine.state` 1209 /// synchronously; only the `sendChanges` drain is deferred — and via 1210 /// `Task.detached`, not a plain `Task {}`. The completion-ack consume path 1211 /// (`presentPings` → `consumeIfDirected`) reaches this from inside the 1212 /// `onPings` delegate callback, so an un-detached Task could re-enter 1213 /// CKSyncEngine before the callback unwinds (same class as the 1214 /// friend-invite `fetchChanges` trap). Detaching keeps it off the 1215 /// callback's actor; the drain only needs to land eventually. 1216 func deletePing(recordName: String, gameID: UUID) async { 1217 let ctx = persistence.container.newBackgroundContext() 1218 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1219 let engine = info.scope == .shared ? sharedEngine : privateEngine 1220 guard let engine else { return } 1221 await failPendingPing(recordName: recordName) 1222 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1223 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1224 sendChangesDetached(on: engine) 1225 } 1226 1227 /// Deletes a Ping from a known non-game zone, currently used for accepted 1228 /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity 1229 /// may not exist before acceptance, so the caller supplies the friend-zone 1230 /// route directly. 1231 func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) async { 1232 let engine = databaseScope == .shared ? sharedEngine : privateEngine 1233 guard let engine else { return } 1234 await failPendingPing(recordName: recordName) 1235 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 1236 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1237 sendChangesDetached(on: engine) 1238 } 1239 1240 /// Registers a Player record as a pending send. One record per 1241 /// (game, authorID), so participants only ever write their own slot. 1242 /// `reason` is logged so the diagnostics view can attribute each enqueue 1243 /// to its caller (rename, read cursor, cursor track, …); it does not 1244 /// affect routing. Drains immediately unless a burst is open for this 1245 /// scope (see `beginPlayerSendBurst`) — the open path uses a burst to 1246 /// ship read cursor + name + initial selection in one round-trip. 1247 /// 1248 /// `drain: false` enqueues the change durably but does not force an 1249 /// immediate `sendChanges()`; the record ships on CKSyncEngine's own 1250 /// schedule instead. Used on the way to the background / on puzzle leave, 1251 /// where a forced drain would race the scarce suspension budget to deliver 1252 /// a presence write the engagement socket already carries live (and that 1253 /// CloudKit delivers durably regardless). 1254 func enqueuePlayer(gameID: UUID, authorID: String, reason: String, drain: Bool = true) async { 1255 let ctx = persistence.container.newBackgroundContext() 1256 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1257 // The shared zone has already been confirmed missing server-side 1258 // (see `applyZoneOrphaning`). Re-saving a Player record would just 1259 // fail with `.zoneNotFound` and drag the orphan handler through 1260 // another round of the same teardown work — open-game UI keeps 1261 // calling here for read-cursor / selection / name-open while the 1262 // user lingers on the revoked puzzle. Silently dropping the enqueue 1263 // is the only sensible response. 1264 guard !info.isAccessRevoked else { 1265 await trace( 1266 "enqueue Player[\(gameID.uuidString.prefix(8))] skipped " + 1267 "(access revoked) reason=\(reason)" 1268 ) 1269 return 1270 } 1271 let engine = info.scope == .shared ? sharedEngine : privateEngine 1272 guard let engine else { return } 1273 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 1274 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1275 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1276 await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)") 1277 // Durably enqueued above. A `drain: false` caller leaves the send to 1278 // CKSyncEngine's automatic scheduling — and skips the burst-pending 1279 // mark too, so a concurrent burst won't drain on this record's behalf. 1280 guard drain else { return } 1281 if (playerSendBurstDepth[info.scope] ?? 0) > 0 { 1282 playerSendBurstPending.insert(info.scope) 1283 } else { 1284 sendChangesDetached(on: engine) 1285 } 1286 } 1287 1288 /// Registers a Game record as a pending send and ensures its zone is 1289 /// created in CloudKit first. Called when a new game is created locally. 1290 func enqueueGame(ckRecordName: String) { 1291 guard let gameID = gameID(fromRecordName: ckRecordName) else { return } 1292 let ctx = persistence.container.newBackgroundContext() 1293 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1294 let engine = info.scope == .shared ? sharedEngine : privateEngine 1295 guard let engine else { return } 1296 // Save the zone before the game record so it exists when records arrive. 1297 engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))]) 1298 let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID) 1299 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1300 sendChangesDetached(on: engine) 1301 } 1302 1303 /// Registers this device's move journal as a pending send, into the game's 1304 /// existing zone (no `saveZone` — by completion the zone is long since 1305 /// created). One `Journal` record per (game, author, device); the asset is 1306 /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so 1307 /// no payload is stashed here. Called once at completion; a re-send is a 1308 /// benign conflict the send path drops. Skipped on an access-revoked game, 1309 /// where any save would just fail with `.zoneNotFound`. 1310 func enqueueJournalUpload(gameID: UUID, authorID: String) { 1311 let ctx = persistence.container.newBackgroundContext() 1312 guard let info = zoneInfo(forGameID: gameID, in: ctx), 1313 !info.isAccessRevoked else { return } 1314 // A device that logged nothing produces no JournalEntity rows, so the 1315 // build-time reap drops the save before it reaches CloudKit — an empty 1316 // journal never uploads, so it can't add a phantom device key to 1317 // replay's present set. No need to guard the enqueue itself. 1318 let engine = info.scope == .shared ? sharedEngine : privateEngine 1319 guard let engine else { return } 1320 let recordName = RecordSerializer.recordName( 1321 forJournalInGame: gameID, 1322 authorID: authorID, 1323 deviceID: RecordSerializer.localDeviceID 1324 ) 1325 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1326 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1327 sendChangesDetached(on: engine) 1328 } 1329 1330 // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) 1331 1332 func fetchChanges(source: String = "manual") async throws { 1333 currentFetchSource = source 1334 defer { currentFetchSource = nil } 1335 async let p: Void = privateEngine?.fetchChanges() ?? () 1336 async let s: Void = sharedEngine?.fetchChanges() ?? () 1337 _ = try await (p, s) 1338 } 1339 1340 /// Zone-scoped fetch for a single game. Returns `false` if the game's zone 1341 /// isn't known locally (e.g. a freshly-invited share before its zone has 1342 /// landed) so the caller can fall back to a full `fetchChanges`. Records 1343 /// arrive via the normal `fetchedRecordZoneChanges` delegate path; the 1344 /// engine's change token is the only checkpoint. 1345 func fetchChangesForGame( 1346 scope: CKDatabase.Scope, 1347 gameID: UUID, 1348 source: String = "manual" 1349 ) async throws -> Bool { 1350 let engine: CKSyncEngine? 1351 let scopeValue: DatabaseScope 1352 switch scope { 1353 case .private: 1354 engine = privateEngine 1355 scopeValue = .private 1356 case .shared: 1357 engine = sharedEngine 1358 scopeValue = .shared 1359 case .public: 1360 return false 1361 @unknown default: 1362 return false 1363 } 1364 guard let engine else { return false } 1365 let ctx = persistence.container.newBackgroundContext() 1366 guard let info = zoneInfo(forGameID: gameID, in: ctx), 1367 info.scope == scopeValue 1368 else { return false } 1369 currentFetchSource = source 1370 defer { currentFetchSource = nil } 1371 let options = CKSyncEngine.FetchChangesOptions(scope: .zoneIDs([info.zoneID])) 1372 try await engine.fetchChanges(options) 1373 return true 1374 } 1375 1376 func pushChanges() async throws { 1377 async let p: Void = privateEngine?.sendChanges() ?? () 1378 async let s: Void = sharedEngine?.sendChanges() ?? () 1379 _ = try await (p, s) 1380 } 1381 1382 // MARK: - Diagnostics 1383 1384 1385 /// Clears the saved state for both engines and replaces the in-memory 1386 /// engine instances so subsequent fetches walk every zone from scratch. 1387 /// Clearing the persisted state alone is ineffective: the running engines 1388 /// hold their tokens in memory and the next `stateUpdate` event saves 1389 /// those tokens back, so the wipe is undone before the user can act on it. 1390 /// Pending records already in CloudKit are unaffected. Locally-unconfirmed 1391 /// moves are re-enqueued so the new engines push them on the next cycle. 1392 func resetSyncState() async { 1393 let ctx = syncStateContext 1394 let failureMessage: String? = await ctx.perform { 1395 let entity = SyncStateEntity.current(in: ctx) 1396 entity.ckPrivateEngineState = nil 1397 entity.ckSharedEngineState = nil 1398 do { 1399 try ctx.save() 1400 return nil 1401 } catch { 1402 return "resetSyncState ctx.save failed — \(error)" 1403 } 1404 } 1405 if let failureMessage { await trace(failureMessage) } 1406 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 1407 database: container.privateCloudDatabase, 1408 stateSerialization: nil, 1409 delegate: self 1410 )) 1411 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 1412 database: container.sharedCloudDatabase, 1413 stateSerialization: nil, 1414 delegate: self 1415 )) 1416 reconcilePendingPingOutbox() 1417 pendingDecisionPayloads = [:] 1418 persistPendingDecisionPayloads() 1419 pendingDecisionVersions = [:] 1420 persistPendingDecisionVersions() 1421 decisionSystemFields = [:] 1422 pingPushCheckpoints = [:] 1423 seenPingRecords = [:] 1424 liveQueryCheckpoints = [:] 1425 loggedFirstSharedPushPayload = false 1426 playerSendBurstDepth = [:] 1427 playerSendBurstPending = [] 1428 _ = await enqueueUnconfirmedMoves() 1429 } 1430 1431 // MARK: - Private helpers 1432 1433 func currentLocalAuthorID() async -> String? { 1434 guard let localAuthorIDProvider else { return nil } 1435 return await MainActor.run { 1436 localAuthorIDProvider() 1437 } 1438 } 1439 1440 1441 func trace(_ message: String) async { 1442 guard let tracer else { return } 1443 await tracer(message) 1444 } 1445 1446 /// Decodes a persisted `CKSyncEngine.State.Serialization` payload. 1447 /// On failure, traces the cold-start cause so it isn't silent in the 1448 /// diagnostics log — the engine will rebuild change tokens and resend 1449 /// pending changes from scratch, which is visible-to-the-user behavior. 1450 private func decodeEngineState(_ data: Data?, label: String) async -> CKSyncEngine.State.Serialization? { 1451 guard let data else { return nil } 1452 do { 1453 return try JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data) 1454 } catch { 1455 await trace("\(label) engine state decode failed (\(data.count) bytes) — cold-starting sync: \(describe(error))") 1456 return nil 1457 } 1458 } 1459 1460 private func saveEngineState( 1461 _ serialization: CKSyncEngine.State.Serialization, 1462 isPrivate: Bool 1463 ) async { 1464 let ctx = syncStateContext 1465 let failureMessage: String? = await ctx.perform { 1466 guard let data = try? JSONEncoder().encode(serialization) else { 1467 return "saveEngineState encode failed — engine state not persisted" 1468 } 1469 let entity = SyncStateEntity.current(in: ctx) 1470 if isPrivate { 1471 entity.ckPrivateEngineState = data 1472 } else { 1473 entity.ckSharedEngineState = data 1474 } 1475 do { 1476 try ctx.save() 1477 return nil 1478 } catch { 1479 return "saveEngineState ctx.save failed — \(error)" 1480 } 1481 } 1482 if let failureMessage { await trace(failureMessage) } 1483 } 1484 1485 private func handleFetchedDatabaseChanges( 1486 _ event: CKSyncEngine.Event.FetchedDatabaseChanges, 1487 isPrivate: Bool 1488 ) async { 1489 let src = currentFetchSource ?? "framework" 1490 await trace( 1491 "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " + 1492 "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions" 1493 ) 1494 await noteRoundTripSuccess() 1495 if isPrivate { 1496 privateCompletedFetchExclusions = nil 1497 } else { 1498 sharedCompletedFetchExclusions = nil 1499 } 1500 1501 if isPrivate, 1502 event.modifications.contains(where: { 1503 $0.zoneID.zoneName == Archive.zoneName 1504 }) { 1505 await MainActor.run { 1506 NotificationCenter.default.post(name: .chronicleZoneDidChange, object: nil) 1507 } 1508 } 1509 1510 // Private-DB zone deletions reflect the user removing one of their own 1511 // games on another device — hard-delete locally so the row stops 1512 // hanging around forever. Shared-DB zone deletions reflect the owner 1513 // removing this account from the share — mark access-revoked instead 1514 // so the UI can surface "no longer have access" rather than silently 1515 // vanishing the row mid-game. Modifications on the shared DB also 1516 // create placeholder GameEntities for newly-joined shares. 1517 let ctx = persistence.container.newBackgroundContext() 1518 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1519 let deletedGameZones = Set(event.deletions.map(\.zoneID).filter { 1520 $0.zoneName.hasPrefix("game-") 1521 }) 1522 let (rejoinedIDs, failureMessages): ([UUID], [String]) = await ctx.perform { 1523 var rejoined: [UUID] = [] 1524 var messages: [String] = [] 1525 if !isPrivate { 1526 for mod in event.modifications { 1527 let zoneID = mod.zoneID 1528 let zoneName = zoneID.zoneName 1529 guard zoneName.hasPrefix("game-") else { continue } 1530 // A zone in the shared database always belongs to somebody 1531 // else — CloudKit rejects any own-owner zone there ("Only 1532 // shared zones can be accessed in the shared DB"). An 1533 // own-owner zone reaching this branch therefore means the 1534 // *private* database's changes were routed here, and 1535 // seating a `databaseScope == 1` row for a zone the user 1536 // owns produces a row no code path can ever heal: the 1537 // private Game record that would fill it in matches on 1538 // `ckZoneOwnerName == NIL` and so forks a second row 1539 // instead, leaving this one permanently empty and shadowing 1540 // the real game in every `id`-keyed lookup. 1541 guard zoneID.ownerName != CKCurrentUserDefaultName else { 1542 messages.append( 1543 "refused shared placeholder for own-owner zone \(zoneName)" 1544 ) 1545 continue 1546 } 1547 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1548 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1549 req.fetchLimit = 1 1550 if (try? ctx.fetch(req).first) == nil { 1551 // Placeholder until the Game record arrives. 1552 let entity = GameEntity(context: ctx) 1553 let uuidString = String(zoneName.dropFirst("game-".count)) 1554 let gid = UUID(uuidString: uuidString) 1555 entity.id = gid 1556 entity.ckRecordName = zoneName 1557 entity.ckZoneName = zoneName 1558 entity.ckZoneOwnerName = zoneID.ownerName 1559 entity.databaseScope = 1 1560 entity.syncVersion = GameSyncVersion.legacy 1561 entity.title = "Joining\u{2026}" 1562 entity.puzzleSource = "" 1563 entity.createdAt = Date() 1564 entity.updatedAt = Date() 1565 // Gaining access to this shared zone means the user 1566 // (re)joined. Any prior `left` decision for this game 1567 // is now void — clear it so a re-invited game isn't 1568 // re-deleted on this or a sibling device by the stale 1569 // durable fact — and any pending invite row for it is 1570 // now redundant. 1571 if let gid { rejoined.append(gid) } 1572 } 1573 } 1574 } 1575 if ctx.hasChanges { 1576 do { 1577 try ctx.save() 1578 } catch { 1579 // A dropped save here loses placeholder rows / revocation 1580 // flags with no redelivery (the change token advances on 1581 // return) — trace it so the diagnostics log shows the drop. 1582 let nsError = error as NSError 1583 messages.append( 1584 "db-changes ctx.save FAILED — domain=\(nsError.domain) " + 1585 "code=\(nsError.code) \(nsError.localizedDescription)" 1586 ) 1587 } 1588 } 1589 return (rejoined, messages) 1590 } 1591 1592 for message in failureMessages { 1593 await trace(message) 1594 } 1595 if !deletedGameZones.isEmpty { 1596 await applyZoneOrphaning(deletedGameZones, isPrivate: isPrivate, source: "fetch") 1597 } 1598 // enqueueDecisionDeletion defers its CKSyncEngine work via Task — it 1599 // never awaits sync from this delegate callback's context. onGameJoined 1600 // only touches Core Data, so awaiting it directly is safe. 1601 for id in rejoinedIDs { 1602 enqueueDecisionDeletion(kind: "left", key: id.uuidString) 1603 if let cb = onGameJoined { await cb(id) } 1604 } 1605 } 1606 1607 private func handleFetchedRecordZoneChanges( 1608 _ event: CKSyncEngine.Event.FetchedRecordZoneChanges, 1609 isPrivate: Bool 1610 ) async { 1611 let scope = DatabaseScope(isPrivate: isPrivate) 1612 let src = currentFetchSource ?? "framework" 1613 await trace( 1614 "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + 1615 "\(event.modifications.count) modifications, \(event.deletions.count) deletions" 1616 ) 1617 await noteRoundTripSuccess() 1618 if !isPrivate, !loggedFirstSharedPushPayload, src == "push", 1619 event.modifications.count + event.deletions.count > 0 { 1620 loggedFirstSharedPushPayload = true 1621 await trace("✅ first shared-DB push payload received — silent-push path is live") 1622 } 1623 1624 let ctx = persistence.container.newBackgroundContext() 1625 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1626 let localAuthorID = await currentLocalAuthorID() 1627 let effects: BatchEffects = await ctx.perform { 1628 var effects = BatchEffects() 1629 for mod in event.modifications { 1630 let record = mod.record 1631 guard !RecordSerializer.isGameScopedRecordType(record.recordType) 1632 || RecordSerializer.isTrustedGameScopedRecord(record) 1633 else { 1634 effects.traces.append( 1635 "rejected untrusted fetched record \(record.recordType) " + 1636 "\(record.recordID.recordName) in \(record.recordID.zoneID.zoneName)" 1637 ) 1638 continue 1639 } 1640 switch record.recordType { 1641 case "Game": 1642 let entity = RecordSerializer.applyGameRecord( 1643 record, 1644 to: ctx, 1645 databaseScope: scope, 1646 onEngagementChange: { effects.engagementChanged.insert($0) }, 1647 onCompletedTransition: { effects.completedTransitions.insert($0) }, 1648 onContentKeyChange: { effects.contentKeysChanged.insert($0) }, 1649 onStaleCredentials: { effects.staleCredentialRecords.insert($0) }, 1650 onDiagnostic: { effects.traces.append($0) } 1651 ) 1652 if let id = entity.id { 1653 effects.rosterRelevant.insert(id) 1654 effects.visibilityCandidateGameIDs.insert(id) 1655 } 1656 case "Moves": 1657 if let value = RecordSerializer.parseMovesRecord(record) { 1658 effects.visibilityCandidateGameIDs.insert(value.gameID) 1659 let cellsChanged = RecordSerializer.applyMovesRecord( 1660 record, 1661 value: value, 1662 to: ctx, 1663 databaseScope: scope, 1664 localAuthorID: localAuthorID, 1665 onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) } 1666 ) 1667 if cellsChanged { effects.movesUpdated.insert(value.gameID) } 1668 } 1669 case "Player": 1670 if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { 1671 effects.visibilityCandidateGameIDs.insert(gameID) 1672 self.applyPlayerRecord( 1673 record, 1674 in: ctx, 1675 databaseScope: scope, 1676 localAuthorID: localAuthorID, 1677 onFirstTime: { effects.playersUpdated.insert($0) }, 1678 onPresenceChange: { effects.playerPresenceChanged.insert($0) }, 1679 onReadCursor: { effects.readCursors.append(($0, $1, $2)) } 1680 ) 1681 effects.rosterRelevant.insert(gameID) 1682 } 1683 case "Ping": 1684 if let ping = Ping.parseRecord(record, fetchedFrom: isPrivate ? .private : .shared) { 1685 effects.pings.append(ping) 1686 } 1687 case "Decision": 1688 if let address = RecordSerializer.parseAccountPushAddressDecision(record, databaseScope: scope) { 1689 effects.accountPushAddresses.append(address) 1690 } 1691 if let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: scope) { 1692 effects.accountPushSecrets.append(parsed) 1693 } 1694 let wrote = RecordSerializer.applyDecisionRecord( 1695 record, 1696 to: ctx, 1697 localAuthorID: localAuthorID, 1698 databaseScope: scope 1699 ) 1700 // The decision-apply path is otherwise silent, which makes 1701 // a "synced fact that never landed" (e.g. a nickname that 1702 // applied on the sender but not here) impossible to diagnose 1703 // from the on-device log. Record the fetched decision name, 1704 // its zone, and whether it applied so the receive side is 1705 // observable. 1706 effects.traces.append( 1707 "decision applied=\(wrote) " + 1708 "\(record.recordID.recordName) " + 1709 "zone=\(record.recordID.zoneID.zoneName)/" + 1710 "\(record.recordID.zoneID.ownerName) scope=\(scope)" 1711 ) 1712 // Our own name Decision echoed back from the account zone 1713 // (or a friend zone a sibling seeded): adopt its version so 1714 // this device's next rename supersedes it rather than 1715 // colliding at an equal generation. 1716 if let (subject, _, version) = RecordSerializer.parseNameDecision(record), 1717 let localAuthorID, subject == localAuthorID { 1718 effects.selfNameVersions.append(version) 1719 } 1720 // A `left` decision hard-deletes a game row; surface it so 1721 // an open PuzzleView / the game list reacts, the same as 1722 // the private zone-deletion path does via onGameRemoved. 1723 if wrote, 1724 let (dKind, dKey) = RecordSerializer.parseDecisionRecordName( 1725 record.recordID.recordName 1726 ) { 1727 if dKind == "left", let gid = UUID(uuidString: dKey) { 1728 effects.removed.insert(gid) 1729 } 1730 if dKind == RecordSerializer.blockDecisionKind { 1731 effects.blockedFriendAuthorIDs.insert(dKey) 1732 } 1733 // A friend's own rename or this user's nickname landed 1734 // — either side of an App Group nickname-directory 1735 // entry, so it's rebuilt after the batch saves. 1736 if dKind == RecordSerializer.nameDecisionKind 1737 || dKind == RecordSerializer.nicknameDecisionKind { 1738 effects.friendNamesChanged = true 1739 } 1740 } 1741 case "Journal": 1742 // Journals are never applied to Core Data from the sync 1743 // delegate — the replay loader (`fetchReplay`) pulls them on 1744 // demand with a plain CKQuery. But the record landing is the 1745 // signal a contributor finished and uploaded, so note the 1746 // game to wake a waiting replay banner and refresh its 1747 // provisional Chronicle (dispatched below). 1748 if let (gid, _, _) = RecordSerializer.parseJournalRecordName( 1749 record.recordID.recordName 1750 ) { 1751 effects.journalsSynced.insert(gid) 1752 } 1753 case Archive.recordType, Archive.legacyRecordType: 1754 // A compact Chronicle or legacy Archive in this user's 1755 // private DB. Inert where the live original still exists; 1756 // hydrated into a standalone completed game on a device 1757 // that lacks it (fresh install / after revocation). 1758 if let id = self.applyArchiveRecord( 1759 record, 1760 in: ctx, 1761 onDiagnostic: { effects.traces.append($0) } 1762 ) { 1763 effects.rosterRelevant.insert(id) 1764 effects.visibilityCandidateGameIDs.insert(id) 1765 } 1766 case CKRecord.SystemType.share: 1767 // The zone-wide share is the authoritative membership. A 1768 // previously accepted participant disappearing from it is 1769 // the departure/revocation signal: rotate the game's push 1770 // credentials so the departed device's cached copy stops 1771 // granting push access. 1772 if let share = record as? CKShare, 1773 let gameID = self.applyShareRecord(share, databaseScope: scope, in: ctx) { 1774 effects.credentialRotations.insert(gameID) 1775 effects.traces.append( 1776 "share roster shrank for \(gameID.uuidString) — rotating push credentials" 1777 ) 1778 } 1779 default: 1780 break 1781 } 1782 } 1783 for deletion in event.deletions { 1784 guard !RecordSerializer.isGameScopedRecordType(deletion.recordType) 1785 || RecordSerializer.isTrustedGameScopedDeletion( 1786 recordID: deletion.recordID, 1787 recordType: deletion.recordType 1788 ) 1789 else { 1790 effects.traces.append( 1791 "rejected untrusted fetched deletion \(deletion.recordType) " + 1792 "\(deletion.recordID.recordName) in \(deletion.recordID.zoneID.zoneName)" 1793 ) 1794 continue 1795 } 1796 self.applyDeletion( 1797 recordID: deletion.recordID, 1798 recordType: deletion.recordType, 1799 databaseScope: scope, 1800 in: ctx 1801 ) 1802 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) { 1803 effects.rosterRelevant.insert(id) 1804 effects.visibilityCandidateGameIDs.insert(id) 1805 } 1806 } 1807 for gameID in effects.movesUpdated { 1808 effects.traces += self.replayCellCache(for: gameID, in: ctx) 1809 } 1810 // CKSyncEngine advances its change token whenever the delegate 1811 // returns from fetchedRecordZoneChanges, regardless of whether we 1812 // persisted anything. A silent failure here means the records are 1813 // gone from the engine's "to deliver" set — they won't come back 1814 // without a `resetSyncState`. Surface failures so we can act — 1815 // through the tracer, which is the only channel that reaches the 1816 // on-device diagnostics log this project debugs Production from. 1817 if ctx.hasChanges { 1818 do { 1819 try ctx.save() 1820 } catch { 1821 let nsError = error as NSError 1822 effects.traces.append( 1823 "fetchedRecordZoneChanges ctx.save FAILED " + 1824 "— domain=\(nsError.domain) code=\(nsError.code) " + 1825 "\(nsError.localizedDescription)" 1826 ) 1827 } 1828 } 1829 // Re-mirror the App Group key directory once the batch is saved, so 1830 // a just-adopted content key is available to the NSE immediately. 1831 if !effects.contentKeysChanged.isEmpty { 1832 GameEntity.rebuildContentKeyDirectory(in: ctx) 1833 } 1834 return effects 1835 } 1836 1837 for message in effects.traces { 1838 await trace(message) 1839 } 1840 if let onGameVisibilityCandidates, !effects.visibilityCandidateGameIDs.isEmpty { 1841 await onGameVisibilityCandidates(effects.visibilityCandidateGameIDs) 1842 } 1843 if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { 1844 await onRemoteMovesUpdated(effects.movesUpdated) 1845 } 1846 if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { 1847 await onRemotePlayersUpdated(effects.playersUpdated) 1848 } 1849 if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty { 1850 await onRemotePlayerPresenceChanged(effects.playerPresenceChanged) 1851 } 1852 if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty { 1853 await onRemoteEngagementChanged(effects.engagementChanged) 1854 } 1855 if let onPushCredentialRotationNeeded, !effects.credentialRotations.isEmpty { 1856 await onPushCredentialRotationNeeded(effects.credentialRotations) 1857 } 1858 if let onRemoteCredentialsChanged, !effects.contentKeysChanged.isEmpty { 1859 await onRemoteCredentialsChanged(effects.contentKeysChanged) 1860 } 1861 if let onIncomingReadCursor, !effects.readCursors.isEmpty { 1862 await onIncomingReadCursor(effects.readCursors) 1863 } 1864 if let onPings, !effects.pings.isEmpty { 1865 await onPings(effects.pings) 1866 } 1867 if let onAccountPushAddress { 1868 for address in effects.accountPushAddresses { 1869 await onAccountPushAddress(address) 1870 } 1871 } 1872 // The push secret converges across the account's own devices purely 1873 // through this inbound path (the account zone lives in the private DB, 1874 // which reliably syncs to every one of the account's devices). On a 1875 // simultaneous-mint race the loser adopts the winner's secret here on 1876 // the next fetch — no send-failure-recovery shortcut needed. 1877 if let onAccountPushSecret { 1878 for entry in effects.accountPushSecrets { 1879 await onAccountPushSecret(entry.secret, entry.version) 1880 } 1881 } 1882 if let localAuthorID, !localAuthorID.isEmpty, 1883 let maxVersion = effects.selfNameVersions.max() { 1884 NameVersionStore.adopt(maxVersion, authorID: localAuthorID) 1885 } 1886 if effects.friendNamesChanged { 1887 let mirrorCtx = persistence.container.newBackgroundContext() 1888 await mirrorCtx.perform { 1889 FriendEntity.rebuildNicknameDirectory(in: mirrorCtx) 1890 } 1891 } 1892 if !effects.blockedFriendAuthorIDs.isEmpty { 1893 await onBlockedFriendsChanged?(effects.blockedFriendAuthorIDs) 1894 } 1895 for id in effects.removed { 1896 if let cb = onGameRemoved { await cb(id) } 1897 } 1898 // Re-push games whose inbound record tried to downgrade a rotated 1899 // credential: the local (newer) value was kept and marked pending, so 1900 // this send heals the server copy. 1901 for recordName in effects.staleCredentialRecords { 1902 enqueueGame(ckRecordName: recordName) 1903 } 1904 // A game just learned it's complete via sync: upload this device's 1905 // journal (no-op if it logged nothing) so replay can converge. The 1906 // enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so 1907 // it never awaits sync from inside this delegate callback. 1908 if let localAuthorID, !localAuthorID.isEmpty { 1909 for id in effects.completedTransitions { 1910 enqueueJournalUpload(gameID: id, authorID: localAuthorID) 1911 } 1912 } 1913 if let onGameCompleted { 1914 for id in effects.completedTransitions { 1915 await onGameCompleted(id) 1916 } 1917 } 1918 let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in 1919 let recordName = deletion.recordID.recordName 1920 guard recordName.hasPrefix("ping-"), 1921 let gameID = gameID(fromRecordName: recordName) 1922 else { return nil } 1923 return (recordName, gameID) 1924 } 1925 if let onPingDeleted, !deletedPings.isEmpty { 1926 await onPingDeleted(deletedPings) 1927 } 1928 if !effects.rosterRelevant.isEmpty { 1929 NotificationCenter.default.post( 1930 name: .playerRosterShouldRefresh, 1931 object: nil, 1932 userInfo: ["gameIDs": effects.rosterRelevant] 1933 ) 1934 } 1935 await notifyReplayJournalsSynced(effects.journalsSynced) 1936 } 1937 1938 func notifyReplayJournalsSynced(_ gameIDs: Set<UUID>) async { 1939 guard !gameIDs.isEmpty else { return } 1940 NotificationCenter.default.post( 1941 name: .replayJournalDidSync, 1942 object: nil, 1943 userInfo: ["gameIDs": gameIDs] 1944 ) 1945 await onReplayJournalsSynced?(gameIDs) 1946 } 1947 1948 private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String { 1949 counts 1950 .filter { $0.value > 0 } 1951 .sorted { lhs, rhs in 1952 if lhs.key == rhs.key { return lhs.value > rhs.value } 1953 return lhs.key < rhs.key 1954 } 1955 .map { "\($0.key)=\($0.value)" } 1956 .joined(separator: ", ") 1957 } 1958 1959 private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String { 1960 let name = recordID.recordName 1961 if name.hasPrefix("moves-") { return "Moves" } 1962 if name.hasPrefix("journal-") { return "Journal" } 1963 if name.hasPrefix("player-") { return "Player" } 1964 if name.hasPrefix("game-") { return "Game" } 1965 if name.hasPrefix("ping-") { return "Ping" } 1966 if name.hasPrefix("decision-") { return "Decision" } 1967 return "Unknown" 1968 } 1969 1970 /// A pending Ping remains durable for conditions CloudKit documents as 1971 /// retryable. These errors instead mean the particular write cannot 1972 /// succeed without a new user action or a corrected request, so leaving it 1973 /// queued would show a clock forever and retain an unusable CKShare seat. 1974 nonisolated static func isTerminalPingSaveError(_ error: NSError) -> Bool { 1975 guard error.domain == CKErrorDomain, 1976 let code = CKError.Code(rawValue: error.code) 1977 else { return false } 1978 switch code { 1979 case .badContainer, 1980 .missingEntitlement, 1981 .notAuthenticated, 1982 .permissionFailure, 1983 .unknownItem, 1984 .invalidArguments, 1985 .serverRejectedRequest, 1986 .incompatibleVersion, 1987 .constraintViolation, 1988 .badDatabase, 1989 .quotaExceeded, 1990 .zoneNotFound, 1991 .limitExceeded, 1992 .userDeletedZone, 1993 .tooManyParticipants, 1994 .referenceViolation, 1995 .managedAccountRestricted: 1996 return true 1997 default: 1998 return false 1999 } 2000 } 2001 2002 nonisolated static func pingSaveRetryDelay(_ error: NSError) -> TimeInterval? { 2003 guard error.domain == CKErrorDomain, 2004 let code = CKError.Code(rawValue: error.code) 2005 else { return nil } 2006 switch code { 2007 case .internalError, 2008 .networkUnavailable, 2009 .networkFailure, 2010 .serviceUnavailable, 2011 .requestRateLimited, 2012 .zoneBusy, 2013 .operationCancelled, 2014 .serverResponseLost, 2015 .accountTemporarilyUnavailable: 2016 let serverDelay = (error.userInfo[CKErrorRetryAfterKey] as? NSNumber)? 2017 .doubleValue 2018 return max(3, serverDelay ?? 0) 2019 default: 2020 return nil 2021 } 2022 } 2023 2024 private func schedulePendingPingRetry( 2025 recordID: CKRecord.ID, 2026 isPrivate: Bool, 2027 after delay: TimeInterval 2028 ) { 2029 let recordName = recordID.recordName 2030 guard pendingPings[recordName] != nil, 2031 scheduledPingRetries.insert(recordName).inserted 2032 else { return } 2033 Task { 2034 try? await Task.sleep(for: .seconds(delay)) 2035 scheduledPingRetries.remove(recordName) 2036 guard !Task.isCancelled, pendingPings[recordName] != nil else { return } 2037 let engine = isPrivate ? privateEngine : sharedEngine 2038 engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 2039 if let engine { sendChangesDetached(on: engine) } 2040 } 2041 } 2042 2043 private func handleSentRecordZoneChanges( 2044 _ event: CKSyncEngine.Event.SentRecordZoneChanges, 2045 isPrivate: Bool 2046 ) async { 2047 var completionRecords: [UUID: Set<CompletionDurableRecordKind>] = [:] 2048 for record in event.savedRecords { 2049 let name = record.recordID.recordName 2050 if record.recordType == "Game", 2051 name.hasPrefix("game-"), 2052 record["completedAt"] as? Date != nil, 2053 let gameID = UUID(uuidString: String(name.dropFirst("game-".count))) { 2054 completionRecords[gameID, default: []].insert(.game) 2055 } else if record.recordType == "Moves", 2056 let gameID = RecordSerializer.parseMovesRecordName(name)?.0 { 2057 completionRecords[gameID, default: []].insert(.moves) 2058 } else if record.recordType == "Journal", 2059 let gameID = RecordSerializer.parseJournalRecordName(name)?.0 { 2060 completionRecords[gameID, default: []].insert(.journal) 2061 } 2062 } 2063 let savedTypes = Dictionary( 2064 grouping: event.savedRecords, 2065 by: \.recordType 2066 ).mapValues(\.count) 2067 let failedTypes = Dictionary( 2068 grouping: event.failedRecordSaves, 2069 by: { $0.record.recordType } 2070 ).mapValues(\.count) 2071 let deletedTypes = Dictionary( 2072 grouping: event.deletedRecordIDs, 2073 by: Self.inferredRecordType(for:) 2074 ).mapValues(\.count) 2075 let savedSummary = Self.recordTypeSummary(savedTypes) 2076 let failedSummary = Self.recordTypeSummary(failedTypes) 2077 let deletedSummary = Self.recordTypeSummary(deletedTypes) 2078 2079 await trace( 2080 "\(isPrivate ? "private" : "shared") sent: " + 2081 "\(event.savedRecords.count) saved" + 2082 "\(savedSummary.isEmpty ? "" : " (\(savedSummary))"), " + 2083 "\(event.failedRecordSaves.count) failed" + 2084 "\(failedSummary.isEmpty ? "" : " (\(failedSummary))"), " + 2085 "\(event.deletedRecordIDs.count) deleted" + 2086 "\(deletedSummary.isEmpty ? "" : " (\(deletedSummary))")" 2087 ) 2088 // Only bump on a clean batch: a round trip with per-record failures 2089 // is not "success" from the user's point of view, and the existing 2090 // SyncMonitor.recordError path owns reporting whatever did break. 2091 if event.failedRecordSaves.isEmpty { 2092 await noteRoundTripSuccess() 2093 } 2094 for record in event.savedRecords { 2095 let name = record.recordID.recordName 2096 if name.hasPrefix("ping-") { 2097 await confirmPendingPing(recordName: name) 2098 } else if name.hasPrefix("decision-") { 2099 let stateKey = Self.decisionStateKey(record.recordID) 2100 pendingDecisionPayloads.removeValue(forKey: stateKey) 2101 persistPendingDecisionPayloads() 2102 pendingDecisionVersions.removeValue(forKey: stateKey) 2103 persistPendingDecisionVersions() 2104 decisionSystemFields.removeValue(forKey: stateKey) 2105 } 2106 } 2107 // Snapshot the intended versions on-actor so the off-actor conflict 2108 // resolution can tell a deliberate, newer write from a stale one. 2109 let pendingVersionsSnapshot = pendingDecisionVersions 2110 let ctx = persistence.container.newBackgroundContext() 2111 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 2112 let (failureMessages, orphanedZones, resolvedDecisions, settledPings, settledJournals, 2113 resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves, 2114 batchFailedSaves, unresolvedFailureZones): 2115 ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, 2116 [String], [(secret: String, version: Int64)], 2117 [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)], 2118 [CKRecord.ID], [CKRecord.ID], Set<CKRecordZone.ID>) = await ctx.perform { 2119 var messages: [String] = [] 2120 var orphaned = Set<CKRecordZone.ID>() 2121 var settledDecisions = Set<CKRecord.ID>() 2122 var settledPings = Set<CKRecord.ID>() 2123 var settledJournals = Set<CKRecord.ID>() 2124 var accountAddresses: [String] = [] 2125 var accountSecrets: [(secret: String, version: Int64)] = [] 2126 // Versioned decisions that lost the change-tag race but win on 2127 // version: (decision state key, server system fields to adopt for 2128 // the overwrite retry). 2129 var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = [] 2130 // Game/Moves/Player saves that lost a change-tag race and had the 2131 // server's system fields written back: re-enqueued below. Like 2132 // decisions, a `serverRecordChanged` failure leaves nothing pending, 2133 // so without the re-add these heal only when unrelated activity 2134 // happens to re-enqueue the record — reliable enough for the 2135 // keystroke-cadence Moves/Player writes, but a Game record (share 2136 // metadata, completion) can strand on an idle game until its next 2137 // change. 2138 var recoveredSaves: [CKRecord.ID] = [] 2139 // Atomic operations report the causal record's substantive error 2140 // and `.batchRequestFailed` for otherwise-valid siblings. The 2141 // latter have already fallen out of CKSyncEngine's pending state; 2142 // retain them for an explicit retry after every causal record in 2143 // their zone has been settled or recovered. 2144 var batchFailedSaves: [CKRecord.ID] = [] 2145 var unresolvedFailureZones = Set<CKRecordZone.ID>() 2146 for record in event.savedRecords { 2147 self.writeBackSystemFields(record: record, in: ctx) 2148 let savedName = record.recordID.recordName 2149 if savedName.hasPrefix("game-") { 2150 self.clearPendingSaveFlag(for: savedName, in: ctx) 2151 } else if savedName.hasPrefix("journal-"), 2152 let (gid, _, _) = RecordSerializer.parseJournalRecordName(savedName) { 2153 // Confirmed durable upload of this device's journal — record 2154 // it so the level-triggered backstop 2155 // (`reconcilePendingJournalUploads`) stops re-enqueuing it. 2156 self.markJournalUploaded(gameID: gid, in: ctx) 2157 } 2158 } 2159 for failure in event.failedRecordSaves { 2160 let name = failure.record.recordID.recordName 2161 let err = failure.error as NSError 2162 // A settled failure is one CloudKit rejected but we treat as 2163 // success (the durable record already exists), so it skips the 2164 // verbose "failed to save" log below — that line reads as an 2165 // error and is pure noise next to the "settled" message. 2166 var settled = false 2167 if err.domain == CKErrorDomain, 2168 err.code == CKError.batchRequestFailed.rawValue { 2169 batchFailedSaves.append(failure.record.recordID) 2170 settled = true 2171 } else if err.domain == CKErrorDomain, 2172 err.code == CKError.zoneNotFound.rawValue { 2173 orphaned.insert(failure.record.recordID.zoneID) 2174 } else if !isPrivate, 2175 self.isInvalidSharedZoneOwnerError(err) { 2176 orphaned.insert(failure.record.recordID.zoneID) 2177 } else if name.hasPrefix("ping-"), 2178 err.domain == CKErrorDomain, 2179 err.code == CKError.serverRecordChanged.rawValue { 2180 // Pings are immutable and their retry keeps the original 2181 // record ID. A conflict therefore means the outbox item is 2182 // already durable (for example, the app terminated after 2183 // CloudKit saved it but before processing the sent event). 2184 settledPings.insert(failure.record.recordID) 2185 settled = true 2186 messages.append("send: ping \(name) already present — settled") 2187 } else if name.hasPrefix("decision-"), 2188 err.domain == CKErrorDomain, 2189 err.code == CKError.serverRecordChanged.rawValue { 2190 let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord 2191 let stateKey = Self.decisionStateKey(failure.record.recordID) 2192 let intended = pendingVersionsSnapshot[stateKey] 2193 let serverVersion = serverRecord.map(RecordSerializer.decisionVersion) 2194 if let intended, let serverRecord, let serverVersion, 2195 intended > serverVersion, 2196 let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) { 2197 // A deliberate, newer write (e.g. a rotated push secret 2198 // or a rename) that lost only the change-tag race. Adopt 2199 // the server's tag so the next build overwrites instead 2200 // of re-colliding, then re-enqueue the save below: a 2201 // `serverRecordChanged` failure does NOT leave the change 2202 // in CKSyncEngine's pending state (the original code 2203 // assumed it did), so the retry must re-add it explicitly 2204 // or the overwrite is silently dropped. 2205 decisionWins.append((failure.record.recordID, stateKey, serverFields)) 2206 settled = true 2207 messages.append( 2208 "send: decision \(name) lost tag race but wins on version " + 2209 "(\(intended) > \(serverVersion)) — overwriting" 2210 ) 2211 } else { 2212 // Write-once (left/pushAddress) or an equal/older 2213 // version: the durable fact already on the server wins. 2214 // Drop the pending change so the record doesn't 2215 // retry-loop, and adopt the winner's payload straight off 2216 // the conflict instead of waiting for a later fetch — for 2217 // the push secret that closes the window in which this 2218 // device would keep deriving divergent per-game addresses. 2219 settledDecisions.insert(failure.record.recordID) 2220 if let serverRecord { 2221 let scope = DatabaseScope(isPrivate: isPrivate) 2222 if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord, databaseScope: scope) { 2223 accountAddresses.append(address) 2224 } 2225 if let parsed = RecordSerializer.parseAccountPushSecretDecision(serverRecord, databaseScope: scope) { 2226 accountSecrets.append(parsed) 2227 } 2228 } 2229 settled = true 2230 messages.append("send: decision \(name) already present — settled") 2231 } 2232 } else if name.hasPrefix("journal-"), 2233 err.domain == CKErrorDomain, 2234 err.code == CKError.serverRecordChanged.rawValue, 2235 let (gid, _, _) = RecordSerializer.parseJournalRecordName(name) { 2236 // Journals are write-once at completion: "record to insert 2237 // already exists" means this device's journal is already 2238 // durable server-side (a backstop re-enqueue, or a first 2239 // upload on a build predating the `journalUploaded` flag). 2240 // There is no system-fields archive to adopt for an update 2241 // and the content is frozen, so the re-send is a no-op — 2242 // settle it like a Decision: drop the pending change and 2243 // mark the game uploaded so `reconcilePendingJournalUploads` 2244 // stops re-enqueuing it. Without this the save fails every 2245 // sweep and `Pending Changes` never drains. 2246 settledJournals.insert(failure.record.recordID) 2247 self.markJournalUploaded(gameID: gid, in: ctx) 2248 settled = true 2249 messages.append("send: journal \(name) already present — settled") 2250 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { 2251 recoveredSaves.append(failure.record.recordID) 2252 settled = true 2253 messages.append( 2254 "send: recovered stale system fields for \(name) from CloudKit server record" 2255 ) 2256 } 2257 guard !settled else { continue } 2258 unresolvedFailureZones.insert(failure.record.recordID.zoneID) 2259 let userInfo = err.userInfo 2260 .map { "\($0.key)=\($0.value)" } 2261 .joined(separator: " | ") 2262 messages.append( 2263 "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)" 2264 ) 2265 } 2266 if ctx.hasChanges { 2267 do { 2268 try ctx.save() 2269 } catch { 2270 let nsError = error as NSError 2271 messages.append( 2272 "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 2273 ) 2274 } 2275 } 2276 return (messages, orphaned, settledDecisions, settledPings, settledJournals, 2277 accountAddresses, accountSecrets, decisionWins, recoveredSaves, 2278 batchFailedSaves, unresolvedFailureZones) 2279 } 2280 if !orphanedZones.isEmpty { 2281 await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send") 2282 } 2283 let terminalPingFailures = event.failedRecordSaves.filter { failure in 2284 failure.record.recordID.recordName.hasPrefix("ping-") 2285 && Self.isTerminalPingSaveError(failure.error as NSError) 2286 } 2287 if !terminalPingFailures.isEmpty { 2288 let engine = isPrivate ? privateEngine : sharedEngine 2289 let changes = terminalPingFailures.map { 2290 CKSyncEngine.PendingRecordZoneChange.saveRecord($0.record.recordID) 2291 } 2292 engine?.state.remove(pendingRecordZoneChanges: changes) 2293 for failure in terminalPingFailures { 2294 let recordName = failure.record.recordID.recordName 2295 guard pendingPings[recordName] != nil else { continue } 2296 await failPendingPing( 2297 recordName: recordName, 2298 error: failure.error as NSError 2299 ) 2300 } 2301 } 2302 for failure in event.failedRecordSaves { 2303 let recordID = failure.record.recordID 2304 guard recordID.recordName.hasPrefix("ping-"), 2305 let delay = Self.pingSaveRetryDelay(failure.error as NSError) 2306 else { continue } 2307 schedulePendingPingRetry( 2308 recordID: recordID, 2309 isPrivate: isPrivate, 2310 after: delay 2311 ) 2312 } 2313 // Settle/retry against the engine this sent-event belongs to: account- 2314 // zone decisions ride the private engine, but a name Decision in a 2315 // joined friend zone rides the shared one. 2316 let decisionEngine = isPrivate ? privateEngine : sharedEngine 2317 if !resolvedDecisions.isEmpty, let decisionEngine { 2318 settleDecisionRecords(resolvedDecisions, on: decisionEngine) 2319 } 2320 // Adopt the server's tag for each versioned decision we're overwriting, 2321 // re-enqueue the save (the failed change is no longer pending), then 2322 // nudge the send loop so the retry (now carrying the tag) goes out 2323 // promptly rather than on CKSyncEngine's own cadence. 2324 if !decisionWins.isEmpty, let decisionEngine { 2325 for win in decisionWins { 2326 decisionSystemFields[win.stateKey] = win.systemFields 2327 decisionEngine.state.add( 2328 pendingRecordZoneChanges: [.saveRecord(win.recordID)] 2329 ) 2330 } 2331 sendChangesDetached(on: decisionEngine) 2332 } 2333 // Re-enqueue Game/Moves/Player saves whose stale system fields we just 2334 // healed: a `serverRecordChanged` failure leaves nothing pending (see 2335 // `decisionWins`), so the now-current record must be re-added or the 2336 // local change waits for the next unrelated enqueue. The engine for 2337 // this sent-event is the same one decisions ride. 2338 if !recoveredSaves.isEmpty, let engine = isPrivate ? privateEngine : sharedEngine { 2339 engine.state.add( 2340 pendingRecordZoneChanges: recoveredSaves.map { .saveRecord($0) } 2341 ) 2342 sendChangesDetached(on: engine) 2343 } 2344 if let onAccountPushAddress { 2345 for address in resolvedAccountAddresses { 2346 await onAccountPushAddress(address) 2347 } 2348 } 2349 if let onAccountPushSecret { 2350 for entry in resolvedAccountSecrets { 2351 await onAccountPushSecret(entry.secret, entry.version) 2352 } 2353 } 2354 if !settledJournals.isEmpty { 2355 // Drop from whichever engine owns the zone (private for solo games, 2356 // shared for collaborations) — unlike decisions, journals ride both. 2357 let engine = isPrivate ? privateEngine : sharedEngine 2358 engine?.state.remove( 2359 pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) } 2360 ) 2361 } 2362 if !settledPings.isEmpty { 2363 let engine = isPrivate ? privateEngine : sharedEngine 2364 engine?.state.remove( 2365 pendingRecordZoneChanges: settledPings.map { .saveRecord($0) } 2366 ) 2367 for recordID in settledPings { 2368 await confirmPendingPing(recordName: recordID.recordName) 2369 } 2370 } 2371 let retryableBatchSiblings = batchFailedSaves.filter { 2372 !orphanedZones.contains($0.zoneID) 2373 && !unresolvedFailureZones.contains($0.zoneID) 2374 } 2375 if !retryableBatchSiblings.isEmpty { 2376 await recoverBatchFailedSaves(retryableBatchSiblings, isPrivate: isPrivate) 2377 } 2378 for message in failureMessages { 2379 await trace(message) 2380 } 2381 if !completionRecords.isEmpty, let onCompletionRecordsSaved { 2382 await onCompletionRecordsSaved(completionRecords) 2383 } 2384 } 2385 2386 private func settleDecisionRecords( 2387 _ recordIDs: Set<CKRecord.ID>, 2388 on engine: CKSyncEngine 2389 ) { 2390 engine.state.remove( 2391 pendingRecordZoneChanges: recordIDs.map { .saveRecord($0) } 2392 ) 2393 for recordID in recordIDs { 2394 let stateKey = Self.decisionStateKey(recordID) 2395 pendingDecisionPayloads.removeValue(forKey: stateKey) 2396 pendingDecisionVersions.removeValue(forKey: stateKey) 2397 decisionSystemFields.removeValue(forKey: stateKey) 2398 } 2399 persistPendingDecisionPayloads() 2400 persistPendingDecisionVersions() 2401 } 2402 2403 /// Re-adds only atomic-failure siblings whose records can still be built. 2404 /// The caller invokes this after resolving all substantive failures in the 2405 /// zone, preventing a blind retry of the original atomic batch. 2406 private func recoverBatchFailedSaves( 2407 _ recordIDs: [CKRecord.ID], 2408 isPrivate: Bool 2409 ) async { 2410 guard let engine = isPrivate ? privateEngine : sharedEngine else { return } 2411 let scope = DatabaseScope(isPrivate: isPrivate) 2412 let pingSnapshot = pendingPings 2413 let decisionSnapshot = pendingDecisionPayloads 2414 let decisionVersionSnapshot = pendingDecisionVersions 2415 let decisionSystemFieldsSnapshot = decisionSystemFields 2416 let ctx = persistence.container.newBackgroundContext() 2417 var recovered: [CKRecord.ID] = [] 2418 for recordID in recordIDs { 2419 guard buildRecord( 2420 for: recordID, 2421 in: ctx, 2422 databaseScope: scope, 2423 pings: pingSnapshot, 2424 decisions: decisionSnapshot, 2425 decisionVersions: decisionVersionSnapshot, 2426 decisionSystemFields: decisionSystemFieldsSnapshot 2427 ) != nil else { 2428 await trace( 2429 "send: atomic sibling \(recordID.recordName) is no longer reconstructable — dropped" 2430 ) 2431 continue 2432 } 2433 recovered.append(recordID) 2434 } 2435 guard !recovered.isEmpty else { return } 2436 engine.state.add( 2437 pendingRecordZoneChanges: recovered.map { .saveRecord($0) } 2438 ) 2439 await trace( 2440 "send: re-enqueued \(recovered.count) reconstructable atomic-failure sibling" + 2441 (recovered.count == 1 ? "" : "s") 2442 ) 2443 sendChangesDetached(on: engine) 2444 } 2445 2446 /// Test seam for the atomic-failure recovery sequence. CKSyncEngine's sent 2447 /// event has no public initializer, so tests supply the causal Decision and 2448 /// collateral record IDs directly after arranging their pending payloads. 2449 func recoverAtomicBatchForTesting( 2450 settledDecisionID: CKRecord.ID, 2451 batchFailedRecordIDs: [CKRecord.ID], 2452 scope: CKDatabase.Scope 2453 ) async { 2454 guard let engine = scope == .shared ? sharedEngine : privateEngine else { return } 2455 let allIDs = [settledDecisionID] + batchFailedRecordIDs 2456 engine.state.remove( 2457 pendingRecordZoneChanges: allIDs.map { .saveRecord($0) } 2458 ) 2459 settleDecisionRecords([settledDecisionID], on: engine) 2460 await recoverBatchFailedSaves( 2461 batchFailedRecordIDs, 2462 isPrivate: scope != .shared 2463 ) 2464 } 2465 2466 nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { 2467 let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" } 2468 return values.contains { 2469 $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") || 2470 $0.localizedCaseInsensitiveContains("invalid userId") 2471 } 2472 } 2473 2474 /// Reflects missing game zones locally after CloudKit reports them gone. 2475 /// The same cleanup applies whether the absence arrives as a fetched 2476 /// database-zone deletion or as a `.zoneNotFound` send/query failure: drop 2477 /// pending sends that target the zone, delete private games or mark shared 2478 /// games access-revoked, and notify upstream observers. Without this, 2479 /// queued changes can retry forever and `Last Error` stays stuck on 2480 /// `Failed to send changes` indefinitely. 2481 /// Internal-rather-than-private so the test suite can drive it directly; 2482 /// `CKSyncEngine.Event` payloads have no public initializer so we cannot 2483 /// exercise `handleSentRecordZoneChanges` end-to-end. 2484 func applyZoneOrphaning( 2485 _ zones: Set<CKRecordZone.ID>, 2486 isPrivate: Bool, 2487 source: String = "sync" 2488 ) async { 2489 let engine = isPrivate ? privateEngine : sharedEngine 2490 if let engine { 2491 let toRemove = engine.state.pendingRecordZoneChanges.filter { change in 2492 switch change { 2493 case .saveRecord(let id): 2494 return zones.contains(id.zoneID) 2495 case .deleteRecord(let id): 2496 return zones.contains(id.zoneID) 2497 @unknown default: 2498 return false 2499 } 2500 } 2501 if !toRemove.isEmpty { 2502 engine.state.remove(pendingRecordZoneChanges: toRemove) 2503 } 2504 } 2505 2506 let orphanedPingNames = pendingPings.compactMap { name, ping in 2507 zones.contains(ping.recordZoneID) ? name : nil 2508 } 2509 for name in orphanedPingNames { 2510 await failPendingPing(recordName: name) 2511 } 2512 2513 let ctx = persistence.container.newBackgroundContext() 2514 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 2515 let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = await ctx.perform { 2516 var removed: [UUID] = [] 2517 var revoked: [UUID] = [] 2518 var messages: [String] = [] 2519 for zone in zones { 2520 let zoneName = zone.zoneName 2521 guard zoneName.hasPrefix("game-") else { continue } 2522 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2523 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 2524 req.fetchLimit = 1 2525 guard let entity = try? ctx.fetch(req).first else { continue } 2526 if isPrivate { 2527 if let id = entity.id { removed.append(id) } 2528 ctx.delete(entity) 2529 } else { 2530 if !entity.isAccessRevoked, let id = entity.id { 2531 revoked.append(id) 2532 } 2533 entity.isAccessRevoked = true 2534 } 2535 } 2536 if ctx.hasChanges { 2537 do { 2538 try ctx.save() 2539 } catch { 2540 let nsError = error as NSError 2541 messages.append( 2542 "orphan-zone ctx.save FAILED — domain=\(nsError.domain) " + 2543 "code=\(nsError.code) \(nsError.localizedDescription)" 2544 ) 2545 } 2546 } 2547 return (removed, revoked, messages) 2548 } 2549 2550 for message in failureMessages { 2551 await trace(message) 2552 } 2553 await trace( 2554 "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) [src=\(source)]: " + 2555 zones.map(\.zoneName).sorted().joined(separator: ", ") 2556 ) 2557 2558 for id in removedIDs { 2559 if let cb = onGameRemoved { await cb(id) } 2560 } 2561 for id in revokedIDs { 2562 if let cb = onGameAccessRevoked { await cb(id) } 2563 } 2564 } 2565 2566 /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the 2567 /// error payload often includes the current server record. Adopt only that 2568 /// record's system fields so a retry can use the fresh change tag while 2569 /// preserving the local values that caused the pending save. 2570 private nonisolated func recoverServerChangedSave( 2571 _ error: Error, 2572 failedRecordName: String, 2573 in ctx: NSManagedObjectContext 2574 ) -> Bool { 2575 let nsError = error as NSError 2576 guard nsError.domain == CKErrorDomain, 2577 nsError.code == CKError.serverRecordChanged.rawValue, 2578 let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord, 2579 serverRecord.recordID.recordName == failedRecordName 2580 else { return false } 2581 2582 writeBackSystemFields(record: serverRecord, in: ctx) 2583 adoptNewerServerCredentials(from: serverRecord, in: ctx) 2584 return true 2585 } 2586 2587 /// The oplock-recovery retry re-pushes the whole local Game record, and 2588 /// while this entity's `hasPendingSave` was set its inbound applies were 2589 /// skipped — so the local `notification` blob may predate a credential 2590 /// rotation a peer performed in the meantime. Re-pushing it verbatim would 2591 /// land the *old* credential back on the server and re-admit a departed 2592 /// participant's push access. Before the retry, adopt the server's 2593 /// credential whenever its rotation generation is at least ours (the tie 2594 /// goes to the server copy, converging concurrent rotations); a *lower* 2595 /// server generation is the stale state the retry exists to heal, so local 2596 /// wins there. 2597 private nonisolated func adoptNewerServerCredentials( 2598 from serverRecord: CKRecord, 2599 in ctx: NSManagedObjectContext 2600 ) { 2601 let name = serverRecord.recordID.recordName 2602 guard name.hasPrefix("game-") else { return } 2603 let serverBlob = (serverRecord["pushCredential"] as? Data) 2604 .flatMap { String(data: $0, encoding: .utf8) } 2605 guard let serverBlob, let serverCreds = GamePushCredentials.decode(serverBlob) else { return } 2606 2607 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2608 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 2609 req.fetchLimit = 1 2610 guard let entity = try? ctx.fetch(req).first, 2611 entity.notification != serverBlob 2612 else { return } 2613 let localGen = GamePushCredentials.decode(entity.notification)?.generation 2614 guard localGen == nil || serverCreds.generation >= localGen! else { return } 2615 entity.notification = serverBlob 2616 // The adopted blob may carry a new content key; re-mirror the App Group 2617 // directory so the NSE can decrypt pushes sealed under it. 2618 GameEntity.rebuildContentKeyDirectory(in: ctx) 2619 } 2620 2621 private nonisolated func writeBackSystemFields( 2622 record: CKRecord, 2623 in ctx: NSManagedObjectContext 2624 ) { 2625 let name = record.recordID.recordName 2626 let entityName: String 2627 if name.hasPrefix("moves-") { entityName = "MovesEntity" } 2628 else if name.hasPrefix("player-") { entityName = "PlayerEntity" } 2629 else if name.hasPrefix("game-") { entityName = "GameEntity" } 2630 else { return } 2631 2632 let req = NSFetchRequest<NSManagedObject>(entityName: entityName) 2633 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 2634 req.fetchLimit = 1 2635 guard let obj = try? ctx.fetch(req).first else { return } 2636 obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields") 2637 if entityName == "GameEntity" { 2638 obj.setValue(Date(), forKey: "lastSyncedAt") 2639 } 2640 } 2641 2642 /// Clears the `hasPendingSave` guard on `GameEntity` after a successful 2643 /// push so future inbound fetches resume adopting server fields. Called 2644 /// from `handleSentRecordZoneChanges` only on confirmed saves — *not* from 2645 /// the oplock-recovery path, which only adopts a fresh etag for retry. 2646 /// Also clears `hasPushPending`, the one-shot flag that forces the next 2647 /// push to re-include the `puzzleSource` asset (used by the NYT re-upgrade 2648 /// path to replace an already-uploaded puzzle). 2649 private nonisolated func clearPendingSaveFlag( 2650 for recordName: String, 2651 in ctx: NSManagedObjectContext 2652 ) { 2653 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2654 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 2655 req.fetchLimit = 1 2656 guard let entity = try? ctx.fetch(req).first else { return } 2657 entity.hasPendingSave = false 2658 entity.hasPushPending = false 2659 } 2660 2661 private nonisolated func markJournalUploaded( 2662 gameID: UUID, 2663 in ctx: NSManagedObjectContext 2664 ) { 2665 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2666 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2667 req.fetchLimit = 1 2668 guard let entity = try? ctx.fetch(req).first else { return } 2669 entity.journalUploaded = true 2670 } 2671 2672 private func completedFetchExclusions( 2673 isPrivate: Bool 2674 ) async -> Set<CKRecordZone.ID> { 2675 if isPrivate, let privateCompletedFetchExclusions { 2676 return privateCompletedFetchExclusions 2677 } 2678 if !isPrivate, let sharedCompletedFetchExclusions { 2679 return sharedCompletedFetchExclusions 2680 } 2681 2682 let database = isPrivate 2683 ? container.privateCloudDatabase 2684 : container.sharedCloudDatabase 2685 do { 2686 let zoneIDs = try await database.allRecordZones() 2687 .map(\.zoneID) 2688 .filter { 2689 RecordSerializer.gameID(fromGameRecordName: $0.zoneName) != nil 2690 } 2691 let recordIDs = zoneIDs.map { 2692 CKRecord.ID(recordName: $0.zoneName, zoneID: $0) 2693 } 2694 let cutoff = Date().addingTimeInterval(-7 * 24 * 60 * 60) 2695 var exclusions = Set<CKRecordZone.ID>() 2696 for start in stride(from: 0, to: recordIDs.count, by: 200) { 2697 let end = min(start + 200, recordIDs.count) 2698 let results = try await database.records( 2699 for: Array(recordIDs[start..<end]), 2700 desiredKeys: ["completedAt"] 2701 ) 2702 for (recordID, result) in results { 2703 guard let record = try? result.get(), 2704 let completedAt = record["completedAt"] as? Date, 2705 completedAt < cutoff 2706 else { continue } 2707 exclusions.insert(recordID.zoneID) 2708 } 2709 } 2710 if isPrivate { 2711 privateCompletedFetchExclusions = exclusions 2712 } else { 2713 sharedCompletedFetchExclusions = exclusions 2714 } 2715 await trace( 2716 "\(isPrivate ? "private" : "shared") completed fetch exclusions: " + 2717 "\(exclusions.count) zone(s)" 2718 ) 2719 return exclusions 2720 } catch { 2721 await trace( 2722 "\(isPrivate ? "private" : "shared") completed fetch exclusions failed: " + 2723 describe(error) 2724 ) 2725 return [] 2726 } 2727 } 2728 2729 } 2730 2731 // MARK: - CKSyncEngineDelegate 2732 2733 extension SyncEngine: CKSyncEngineDelegate { 2734 func nextFetchChangesOptions( 2735 _ context: CKSyncEngine.FetchChangesContext, 2736 syncEngine: CKSyncEngine 2737 ) async -> CKSyncEngine.FetchChangesOptions { 2738 var options = context.options 2739 let isPrivate = scope(for: syncEngine) == .private 2740 var excluded = await completedFetchExclusions(isPrivate: isPrivate) 2741 if isPrivate { excluded.insert(Archive.zoneID) } 2742 2743 switch options.scope { 2744 case .all: 2745 options.scope = .allExcluding(Array(excluded)) 2746 case .allExcluding(var zoneIDs): 2747 let existing = Set(zoneIDs) 2748 for zoneID in excluded where !existing.contains(zoneID) { 2749 zoneIDs.append(zoneID) 2750 } 2751 options.scope = .allExcluding(zoneIDs) 2752 case .zoneIDs(let zoneIDs): 2753 options.scope = .zoneIDs(zoneIDs.filter { 2754 !excluded.contains($0) 2755 }) 2756 @unknown default: 2757 options.scope = .allExcluding(Array(excluded)) 2758 } 2759 return options 2760 } 2761 2762 func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { 2763 let isPrivate = scope(for: syncEngine) == .private 2764 switch event { 2765 case .stateUpdate(let e): 2766 await saveEngineState(e.stateSerialization, isPrivate: isPrivate) 2767 2768 case .accountChange(let e): 2769 await trace("account change: \(e.changeType)") 2770 if let onAccountChange { await onAccountChange() } 2771 2772 case .fetchedDatabaseChanges(let e): 2773 await handleFetchedDatabaseChanges(e, isPrivate: isPrivate) 2774 2775 case .fetchedRecordZoneChanges(let e): 2776 await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate) 2777 2778 case .sentDatabaseChanges: 2779 break 2780 2781 case .sentRecordZoneChanges(let e): 2782 await handleSentRecordZoneChanges(e, isPrivate: isPrivate) 2783 2784 case .willFetchChanges, .didFetchChanges, 2785 .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, 2786 .willSendChanges, .didSendChanges: 2787 break 2788 2789 @unknown default: 2790 break 2791 } 2792 } 2793 2794 func nextRecordZoneChangeBatch( 2795 _ context: CKSyncEngine.SendChangesContext, 2796 syncEngine: CKSyncEngine 2797 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2798 await makeRecordZoneChangeBatch(for: syncEngine) 2799 } 2800 2801 /// Builds the next outbound batch for `engine`, reaping any pending 2802 /// `.saveRecord` whose record can no longer be reconstructed. For a ping 2803 /// that means its durable outbox row was missing or unreadable; for 2804 /// game/moves/player it means the Core Data entity was deleted. Either way 2805 /// the save can never succeed, so the change is 2806 /// dropped instead of returning a nil batch that leaves it queued forever 2807 /// — `Pending Changes` would never drain and no error is ever surfaced. 2808 /// Mirrors Apple's CKSyncEngine reference implementation, which reaps 2809 /// un-buildable changes inside the record provider. Internal-rather-than- 2810 /// private so the test suite can drive it directly; the delegate entry 2811 /// point can't be exercised because `CKSyncEngine.SendChangesContext` has 2812 /// no public initializer. 2813 func makeRecordZoneChangeBatch( 2814 for engine: CKSyncEngine 2815 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2816 let pending = engine.state.pendingRecordZoneChanges 2817 guard !pending.isEmpty else { return nil } 2818 await traceForeignPlayerWrites(in: pending) 2819 let scope = scope(for: engine) 2820 let pingSnapshot = pendingPings 2821 let decisionSnapshot = pendingDecisionPayloads 2822 let decisionVersionSnapshot = pendingDecisionVersions 2823 let decisionSystemFieldsSnapshot = decisionSystemFields 2824 let buildContext = persistence.container.newBackgroundContext() 2825 return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in 2826 guard let self else { return nil } 2827 if let record = self.buildRecord( 2828 for: recordID, 2829 in: buildContext, 2830 databaseScope: scope, 2831 pings: pingSnapshot, 2832 decisions: decisionSnapshot, 2833 decisionVersions: decisionVersionSnapshot, 2834 decisionSystemFields: decisionSystemFieldsSnapshot 2835 ) { 2836 return record 2837 } 2838 engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) 2839 return nil 2840 } 2841 } 2842 2843 /// Diagnostic for the recurring "ghost peer": flags any *peer's* Player 2844 /// slot in our outbound batch. A participant must only ever write its own 2845 /// `(game, authorID)` record — `enqueuePlayer` is keyed to the local 2846 /// author. A foreign authorID here means this device is about to upload 2847 /// someone else's presence, which `RecordBuilder` stamps with our own 2848 /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that 2849 /// horizon is a live read lease, the peer is resurrected as present with 2850 /// our future `presenceUntil`. We log the value the build will send so a future 2851 /// lease (the ghost) is distinguishable from a current-time close, and so 2852 /// the next recurrence names the culprit in one line rather than leaving it 2853 /// to inference. Silent in the normal case (own slot only), so it adds no 2854 /// noise until the bug actually fires. 2855 private func traceForeignPlayerWrites( 2856 in pending: [CKSyncEngine.PendingRecordZoneChange] 2857 ) async { 2858 guard let localAuthorID = await currentLocalAuthorID() else { return } 2859 var foreign: [(UUID, String)] = [] 2860 for change in pending { 2861 guard case .saveRecord(let recordID) = change, 2862 let (gameID, authorID) = 2863 RecordSerializer.parsePlayerRecordName(recordID.recordName), 2864 authorID != localAuthorID 2865 else { continue } 2866 foreign.append((gameID, authorID)) 2867 } 2868 guard !foreign.isEmpty else { return } 2869 let ctx = persistence.container.newBackgroundContext() 2870 for (gameID, authorID) in foreign { 2871 let presenceUntil: Date? = await ctx.perform { 2872 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2873 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2874 req.fetchLimit = 1 2875 return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt 2876 } 2877 let leaseDesc: String 2878 if let presenceUntil { 2879 let delta = Int(presenceUntil.timeIntervalSinceNow) 2880 leaseDesc = "presenceUntil=\(presenceUntil.ISO8601Format()) " + 2881 (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)") 2882 } else { 2883 leaseDesc = "presenceUntil=nil" 2884 } 2885 await trace( 2886 "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " + 2887 "author=\(authorID.prefix(8)) local=\(localAuthorID.prefix(8)) " + 2888 "\(leaseDesc) — uploading a peer's slot" 2889 ) 2890 } 2891 } 2892 2893 /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's 2894 /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing. 2895 func makeRecordZoneChangeBatch( 2896 forTestingScope scope: CKDatabase.Scope 2897 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2898 let engine = scope == .shared ? sharedEngine : privateEngine 2899 guard let engine else { return nil } 2900 return await makeRecordZoneChangeBatch(for: engine) 2901 } 2902 2903 func pendingPingRecordNamesForTesting() -> [String] { 2904 Array(pendingPings.keys).sorted() 2905 } 2906 2907 func confirmPendingPingForTesting(recordName: String) async { 2908 await confirmPendingPing(recordName: recordName) 2909 } 2910 2911 func setPingConfirmationTimeoutForTesting(_ duration: Duration) { 2912 pingConfirmationTimeout = duration 2913 } 2914 }