CloudQuery.swift (46815B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 extension SyncEngine { 6 /// Manual/diagnostic fallback for durable Ping records. Normal 7 /// collaboration no longer polls pings on foreground, push, or puzzle 8 /// open; invites and friendship bootstrap ride normal zone application. 9 @discardableResult 10 func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int { 11 let database: CKDatabase 12 let scopeValue: Int16 13 let label: String 14 switch scope { 15 case .private: 16 database = container.privateCloudDatabase 17 scopeValue = 0 18 label = "private" 19 case .shared: 20 database = container.sharedCloudDatabase 21 scopeValue = 1 22 label = "shared" 23 case .public: 24 return 0 25 @unknown default: 26 return 0 27 } 28 29 let ctx = persistence.container.newBackgroundContext() 30 // Completed puzzles are excluded: the fast path only shaves push 31 // latency for live collaboration, and finished zones' late pings 32 // still land via CKSyncEngine's own change fetch. This trims the 33 // per-push fan-out from every known zone to just the active ones. 34 let zones = knownZones( 35 forScope: scopeValue, 36 onlyIncomplete: true, 37 in: ctx 38 ) 39 guard !zones.isEmpty else { 40 await trace("\(label) ping fast-path: no known zones") 41 return 0 42 } 43 44 let scopeCheckpoint = pingPushCheckpoints[scopeValue]? 45 .addingTimeInterval(-pingPushCheckpointOverlap) 46 47 // Fan the per-zone Ping queries out concurrently. The actor's await 48 // points release isolation between round-trips, so the per-zone CK 49 // requests overlap; a serial N-zone scan becomes a single parallel 50 // batch. Per-zone errors are caught and traced so one transient 51 // failure doesn't suppress notifications from healthy zones. 52 struct PerZonePings: Sendable { 53 let records: [CKRecord] 54 let orphanedZone: CKRecordZone.ID? 55 } 56 let perZoneRecords = await withTaskGroup(of: PerZonePings.self) { group in 57 for (zoneID, createdAt) in zones { 58 // Scope checkpoint (if present) wins — it's forward-moving 59 // across all zones. On first run for a given scope we fall 60 // back to the game's createdAt floor so the ping that 61 // triggered this wake is still in range, but pings older 62 // than the device's first knowledge of the game are not. 63 let since = scopeCheckpoint 64 ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap) 65 group.addTask { [weak self] in 66 guard let self else { return PerZonePings(records: [], orphanedZone: nil) } 67 do { 68 let records = try await self.queryLiveRecords( 69 type: "Ping", 70 database: database, 71 zoneID: zoneID, 72 since: since, 73 desiredKeys: RecordSerializer.pingDesiredKeys 74 ) 75 return PerZonePings(records: records, orphanedZone: nil) 76 } catch { 77 let orphan: CKRecordZone.ID? 78 if scope == .shared, 79 self.isInvalidSharedZoneOwnerError(error as NSError) { 80 orphan = zoneID 81 } else { 82 orphan = nil 83 } 84 await self.trace( 85 "\(label) ping fast-path: zone \(zoneID.zoneName) failed: " + 86 "\(error.localizedDescription)" 87 ) 88 return PerZonePings(records: [], orphanedZone: orphan) 89 } 90 } 91 } 92 var all: [PerZonePings] = [] 93 for await batch in group { 94 all.append(batch) 95 } 96 return all 97 } 98 let collected: [CKRecord] = perZoneRecords.flatMap(\.records) 99 100 // Dedupe by record name: the overlap window re-fetches recent pings on 101 // every push, so emit each only on first sighting. Without this the 102 // newest ping re-fires forever — the floor is the stored checkpoint 103 // minus the overlap, so `modificationDate > floor` always re-matches it. 104 var seen = seenPingRecords[scopeValue] ?? [:] 105 var pings: [Ping] = [] 106 var fetchedCount = 0 107 for record in collected { 108 guard let ping = Ping.parseRecord(record) else { continue } 109 fetchedCount += 1 110 let modDate = record.modificationDate ?? Date() 111 if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { 112 pings.append(ping) 113 } 114 } 115 116 // Advance the checkpoint monotonically — `max(prior, latest)`, never 117 // `= latest` — so a slow zone's older batch can't drag every zone's 118 // window backward. 119 if let latest = collected.compactMap(\.modificationDate).max() { 120 let prior = pingPushCheckpoints[scopeValue] ?? .distantPast 121 pingPushCheckpoints[scopeValue] = max(prior, latest) 122 } 123 // Forget names the next query's floor can no longer return, keeping 124 // the seen set bounded to the overlap window rather than the session. 125 if let checkpoint = pingPushCheckpoints[scopeValue] { 126 let floor = checkpoint.addingTimeInterval(-pingPushCheckpointOverlap) 127 seen = seen.filter { $0.value >= floor } 128 } 129 seenPingRecords[scopeValue] = seen 130 131 let orphans = Set(perZoneRecords.compactMap(\.orphanedZone)) 132 if !orphans.isEmpty { 133 await applyZoneOrphaning(orphans, isPrivate: scope == .private) 134 } 135 136 await trace( 137 "\(label) ping fast-path: zones=\(zones.count), " + 138 "pings=\(pings.count), dup=\(fetchedCount - pings.count)" 139 ) 140 141 if !pings.isEmpty, let onPings { 142 await onPings(pings) 143 } 144 return pings.count 145 } 146 147 /// Narrow fallback for game-list invite delivery. Re-invites live in 148 /// pairwise friend zones, so game-list Game/Moves refreshes will never 149 /// see them. This scans only friend zones and only `.invite` records, 150 /// leaving broad Ping polling out of the live collaboration path. 151 @discardableResult 152 func fetchFriendInvitesDirect(scope: CKDatabase.Scope) async throws -> Int { 153 let database: CKDatabase 154 let scopeValue: Int16 155 let label: String 156 switch scope { 157 case .private: 158 database = container.privateCloudDatabase 159 scopeValue = 0 160 label = "private" 161 case .shared: 162 database = container.sharedCloudDatabase 163 scopeValue = 1 164 label = "shared" 165 case .public: 166 return 0 167 @unknown default: 168 return 0 169 } 170 171 let targets = friendInviteScanTargets(forScope: scopeValue) 172 guard !targets.isEmpty else { 173 await trace("\(label) invite sync: no friend zones") 174 return 0 175 } 176 177 struct PerZoneInvites: Sendable { 178 let pings: [Ping] 179 let recordNames: Set<String> 180 let scannedAuthorID: String? 181 let orphanedZone: CKRecordZone.ID? 182 } 183 let perZone = await withTaskGroup(of: PerZoneInvites.self) { group in 184 for target in targets { 185 group.addTask { [weak self] in 186 guard let self else { 187 return PerZoneInvites(pings: [], recordNames: [], scannedAuthorID: nil, orphanedZone: nil) 188 } 189 do { 190 let records = try await self.queryRecords( 191 type: "Ping", 192 database: database, 193 zoneID: target.zoneID, 194 predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), 195 desiredKeys: RecordSerializer.pingDesiredKeys 196 ) 197 return PerZoneInvites( 198 pings: records.compactMap(Ping.parseRecord), 199 recordNames: Set(records.map(\.recordID.recordName)), 200 scannedAuthorID: target.authorID, 201 orphanedZone: nil 202 ) 203 } catch { 204 let orphan: CKRecordZone.ID? 205 if scope == .shared, 206 self.isInvalidSharedZoneOwnerError(error as NSError) { 207 orphan = target.zoneID 208 } else { 209 orphan = nil 210 } 211 await self.trace( 212 "\(label) invite sync: zone \(target.zoneID.zoneName) failed: " + 213 "\(error.localizedDescription)" 214 ) 215 return PerZoneInvites(pings: [], recordNames: [], scannedAuthorID: nil, orphanedZone: orphan) 216 } 217 } 218 } 219 220 var all: [PerZoneInvites] = [] 221 for await result in group { 222 all.append(result) 223 } 224 return all 225 } 226 227 let orphans = Set(perZone.compactMap(\.orphanedZone)) 228 if !orphans.isEmpty { 229 await applyZoneOrphaning(orphans, isPrivate: scope == .private) 230 } 231 232 let pings = perZone.flatMap(\.pings) 233 let pruned = await pruneMissingPendingInvites( 234 fromScannedInviters: Set(perZone.compactMap(\.scannedAuthorID)), 235 livePingRecordNames: perZone.reduce(into: Set<String>()) { $0.formUnion($1.recordNames) }, 236 label: label 237 ) 238 await trace("\(label) invite sync: zones=\(targets.count), invites=\(pings.count), pruned=\(pruned)") 239 if !pings.isEmpty, let onPings { 240 await onPings(pings) 241 } 242 return pings.count 243 } 244 245 private func friendInviteScanTargets( 246 forScope scope: Int16 247 ) -> [(zoneID: CKRecordZone.ID, authorID: String)] { 248 let ctx = persistence.container.newBackgroundContext() 249 return ctx.performAndWait { 250 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 251 req.predicate = NSPredicate( 252 format: "databaseScope == %d AND isBlocked == NO", 253 scope 254 ) 255 var seen = Set<String>() 256 var result: [(zoneID: CKRecordZone.ID, authorID: String)] = [] 257 for friend in (try? ctx.fetch(req)) ?? [] { 258 guard let zoneName = friend.friendZoneName, 259 let ownerName = friend.friendZoneOwnerName, 260 let authorID = friend.authorID 261 else { continue } 262 let key = "\(ownerName)|\(zoneName)" 263 guard seen.insert(key).inserted else { continue } 264 result.append(( 265 zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), 266 authorID: authorID 267 )) 268 } 269 return result 270 } 271 } 272 273 /// Friend-zone invite scans are authoritative for the zones that completed. 274 /// If a durable local InviteEntity still points at a Ping record absent 275 /// from that scan, the source message has already been consumed elsewhere 276 /// and the local row is stale. 277 private func pruneMissingPendingInvites( 278 fromScannedInviters scannedInviterAuthorIDs: Set<String>, 279 livePingRecordNames: Set<String>, 280 label: String 281 ) async -> Int { 282 guard !scannedInviterAuthorIDs.isEmpty else { return 0 } 283 let ctx = persistence.container.newBackgroundContext() 284 let result: Result<Int, Error> = ctx.performAndWait { 285 let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") 286 req.predicate = NSPredicate( 287 format: "status == %@ AND inviterAuthorID IN %@", 288 "pending", 289 Array(scannedInviterAuthorIDs) 290 ) 291 let rows = (try? ctx.fetch(req)) ?? [] 292 var removed = 0 293 for row in rows { 294 guard let recordName = row.pingRecordName, 295 !livePingRecordNames.contains(recordName) 296 else { continue } 297 ctx.delete(row) 298 removed += 1 299 } 300 guard removed > 0 else { return .success(0) } 301 do { 302 try ctx.save() 303 return .success(removed) 304 } catch { 305 ctx.rollback() 306 return .failure(error) 307 } 308 } 309 switch result { 310 case .success(let removed): 311 return removed 312 case .failure(let error): 313 await trace("\(label) invite sync: stale local invite prune failed: \(error.localizedDescription)") 314 return 0 315 } 316 } 317 318 /// Deletes the `.invite` Ping(s) for `gameID` from the user's friend zones. 319 /// Called when leaving a shared game: the invite Ping is durable and its 320 /// only other cleanup, `consumeStaleInvites`, keys off a local `GameEntity` 321 /// that leaving has just removed — so without this the invite resurrects as 322 /// a fresh card on the next cold start (and on any sibling device that 323 /// re-syncs it). Deleting the source record clears it everywhere; a later 324 /// re-invite is a new Ping with its own record name and still surfaces. 325 /// Best-effort: a per-zone query failure is traced and skipped so leaving 326 /// still completes. Scans both scopes because invite Pings live in either a 327 /// private or shared friend zone depending on how the pair befriended. 328 func deleteInvitePingsAfterLeave(forGameID gameID: UUID) async { 329 for (scopeValue, database) in [ 330 (Int16(0), container.privateCloudDatabase), 331 (Int16(1), container.sharedCloudDatabase) 332 ] { 333 for zoneID in friendZoneIDs(forScope: scopeValue) { 334 let records: [CKRecord] 335 do { 336 records = try await queryRecords( 337 type: "Ping", 338 database: database, 339 zoneID: zoneID, 340 predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), 341 desiredKeys: RecordSerializer.pingDeletionDesiredKeys 342 ) 343 } catch { 344 await trace( 345 "leave invite cleanup: zone \(zoneID.zoneName) query failed: " + 346 "\(error.localizedDescription)" 347 ) 348 continue 349 } 350 for record in records { 351 guard let ping = Ping.parseRecord(record), ping.gameID == gameID else { continue } 352 deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) 353 await trace( 354 "leave invite cleanup: deleting invite ping \(ping.recordName) " + 355 "for \(gameID.uuidString)" 356 ) 357 } 358 } 359 } 360 } 361 362 /// Lightweight background read for session presence. This intentionally 363 /// reads only Player records; Ping records are durable bootstrap state, 364 /// not part of the live/background notification path. 365 @discardableResult 366 func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> [Session] { 367 let database: CKDatabase 368 let scopeValue: Int16 369 let label: String 370 switch scope { 371 case .private: 372 database = container.privateCloudDatabase 373 scopeValue = 0 374 label = "private" 375 case .shared: 376 database = container.sharedCloudDatabase 377 scopeValue = 1 378 label = "shared" 379 case .public: 380 return [] 381 @unknown default: 382 return [] 383 } 384 385 let ctx = persistence.container.newBackgroundContext() 386 let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) 387 guard !zones.isEmpty else { 388 await trace("\(label) background session scan: no incomplete zones") 389 return [] 390 } 391 392 let since = Date().addingTimeInterval(-backgroundSessionLookback) 393 struct PerZoneActivity: Sendable { 394 let records: [CKRecord] 395 let players: [Session] 396 let orphanedZone: CKRecordZone.ID? 397 } 398 399 let perZone = await withTaskGroup(of: PerZoneActivity.self) { group in 400 for zone in zones { 401 group.addTask { [weak self] in 402 guard let self else { 403 return PerZoneActivity(records: [], players: [], orphanedZone: nil) 404 } 405 do { 406 let playerRecords = try await self.queryLiveRecords( 407 type: "Player", 408 database: database, 409 zoneID: zone.zoneID, 410 since: since, 411 desiredKeys: RecordSerializer.playerDesiredKeys 412 ) 413 let activities = playerRecords.compactMap { record in 414 Session.parseRecord(record, puzzleTitle: zone.title) 415 } 416 return PerZoneActivity( 417 records: playerRecords, 418 players: activities, 419 orphanedZone: nil 420 ) 421 } catch { 422 let orphan: CKRecordZone.ID? 423 if scope == .shared, 424 self.isInvalidSharedZoneOwnerError(error as NSError) { 425 orphan = zone.zoneID 426 } else { 427 orphan = nil 428 } 429 await self.trace( 430 "\(label) background session scan: zone \(zone.zoneID.zoneName) failed: " + 431 "\(error.localizedDescription)" 432 ) 433 return PerZoneActivity( 434 records: [], 435 players: [], 436 orphanedZone: orphan 437 ) 438 } 439 } 440 } 441 var all: [PerZoneActivity] = [] 442 for await result in group { 443 all.append(result) 444 } 445 return all 446 } 447 448 let records = perZone.flatMap(\.records) 449 if !records.isEmpty { 450 await applyDirectRecordZoneChanges( 451 records: records, 452 deletions: [], 453 scopeValue: scopeValue 454 ) 455 } 456 457 let orphans = Set(perZone.compactMap(\.orphanedZone)) 458 if !orphans.isEmpty { 459 await applyZoneOrphaning(orphans, isPrivate: scope == .private) 460 } 461 462 let players = perZone.flatMap(\.players) 463 await trace( 464 "\(label) background session scan: zones=\(zones.count), " + 465 "players=\(players.count)" 466 ) 467 return players 468 } 469 470 /// Discovers games whose zones the device has never seen and pulls their 471 /// Game / Moves / Player records directly, bypassing CKSyncEngine. 472 /// 473 /// CKSyncEngine is supposed to deliver database-scope change events that 474 /// announce new zones, but on a silent-push wake those events can be 475 /// withheld until the next foreground (the same quirk that motivated 476 /// `fetchLiveGameDirect` and `fetchPushPingsDirect`). Without zone 477 /// discovery, a game created on one device only appears on a second 478 /// device after CKSyncEngine eventually catches up — which can be a long 479 /// time if the second device only ever opens the app briefly. 480 /// 481 /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against 482 /// `knownZones`, and pulls every record type we care about for each new 483 /// zone. The pull is unbounded in time because, by definition, the 484 /// device has no checkpoint for a zone it hasn't seen. 485 /// 486 /// Returns the number of newly-discovered zones. 487 @discardableResult 488 func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int { 489 let database: CKDatabase 490 let scopeValue: Int16 491 let label: String 492 switch scope { 493 case .private: 494 database = container.privateCloudDatabase 495 scopeValue = 0 496 label = "private" 497 case .shared: 498 database = container.sharedCloudDatabase 499 scopeValue = 1 500 label = "shared" 501 case .public: 502 return 0 503 @unknown default: 504 return 0 505 } 506 507 let serverZones = try await database.allRecordZones() 508 let ctx = persistence.container.newBackgroundContext() 509 let known = knownZones(forScope: scopeValue, in: ctx) 510 let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" }) 511 512 // Server zones not already tracked as a game or friend zone. 513 let untracked = serverZones 514 .map(\.zoneID) 515 .filter { id in 516 id != CKRecordZone.ID.default && 517 !knownKeys.contains("\(id.ownerName)|\(id.zoneName)") 518 } 519 // Of those, skip the ones this probe can never resolve to a game: the 520 // account-scoped zone and the private-DB archive backups of finished 521 // shared games. Archive records arrive through the engine's own 522 // fetchedRecordZoneChanges, not this Game query, so probing them here 523 // only re-fans a wasted query on every pass. 524 let candidates = untracked.filter { id in 525 id.zoneName != RecordSerializer.accountZoneID.zoneName && 526 !Archive.isArchiveZone(id.zoneName) 527 } 528 let nonGameCount = untracked.count - candidates.count 529 530 guard !candidates.isEmpty else { 531 // Count non-default server zones so the figure matches what the 532 // candidate filter actually compares: the private DB's 533 // allRecordZones() always includes _defaultZone, which knownZones 534 // never tracks, so a raw serverZones.count reads a permanent +1. 535 let serverNonDefault = serverZones.lazy 536 .filter { $0.zoneID != .default } 537 .count 538 // Name the account/archive zones held back above so the figures 539 // reconcile at a glance: known games + non-game = server. 540 let nonGameSuffix = nonGameCount > 0 ? ", non-game=\(nonGameCount)" : "" 541 await trace( 542 "\(label) zone discovery: nothing new " + 543 "(server=\(serverNonDefault), known=\(known.count)\(nonGameSuffix))" 544 ) 545 return 0 546 } 547 548 // Probe Game first. Most candidate zones are expected to be Crossmate 549 // zones, but this path also sees friend/account/debug zones; fetching 550 // Moves and Player before proving there is a Game record turns zone 551 // discovery into a three-query fan-out for every non-game zone. 552 struct PerZoneResult: Sendable { 553 let records: [CKRecord] 554 let hasGame: Bool 555 } 556 let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in 557 for zoneID in candidates { 558 group.addTask { [weak self] in 559 guard let self else { 560 return PerZoneResult(records: [], hasGame: false) 561 } 562 do { 563 let games = try await self.queryLiveRecords( 564 type: "Game", 565 database: database, 566 zoneID: zoneID, 567 since: nil, 568 desiredKeys: RecordSerializer.gameDesiredKeys 569 ) 570 guard !games.isEmpty else { 571 return PerZoneResult(records: [], hasGame: false) 572 } 573 async let moves = try await self.queryLiveRecords( 574 type: "Moves", 575 database: database, 576 zoneID: zoneID, 577 since: nil, 578 desiredKeys: RecordSerializer.movesDesiredKeys 579 ) 580 async let players = try await self.queryLiveRecords( 581 type: "Player", 582 database: database, 583 zoneID: zoneID, 584 since: nil, 585 desiredKeys: RecordSerializer.playerDesiredKeys 586 ) 587 let (m, p) = try await (moves, players) 588 return PerZoneResult(records: games + m + p, hasGame: true) 589 } catch { 590 await self.trace( 591 "\(label) zone discovery: zone \(zoneID.zoneName) failed: " + 592 "\(error.localizedDescription)" 593 ) 594 return PerZoneResult(records: [], hasGame: false) 595 } 596 } 597 } 598 var all: [PerZoneResult] = [] 599 for await result in group { 600 all.append(result) 601 } 602 return all 603 } 604 let collected: [CKRecord] = perZoneResults.flatMap(\.records) 605 let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 } 606 607 await applyDirectRecordZoneChanges( 608 records: collected, 609 deletions: [], 610 scopeValue: scopeValue 611 ) 612 613 await trace( 614 "\(label) zone discovery: candidates=\(candidates.count), " + 615 "withGame=\(zonesWithGame), records=\(collected.count)" 616 ) 617 return zonesWithGame 618 } 619 620 /// Foreground/open-puzzle catch-up for a single game zone. This is the 621 /// latency-sensitive collaboration path, so it bypasses CKSyncEngine's 622 /// broader fetch and pulls only the records the active grid needs. 623 /// 624 /// Returns `false` when the game zone is not known locally yet, allowing 625 /// the caller to fall back to CKSyncEngine for the first discovery pass. 626 @discardableResult 627 func fetchGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { 628 let database: CKDatabase 629 let scopeValue: Int16 630 let label: String 631 switch scope { 632 case .private: 633 database = container.privateCloudDatabase 634 scopeValue = 0 635 label = "private" 636 case .shared: 637 database = container.sharedCloudDatabase 638 scopeValue = 1 639 label = "shared" 640 case .public: 641 return false 642 @unknown default: 643 return false 644 } 645 646 let ctx = persistence.container.newBackgroundContext() 647 guard let info = zoneInfo(forGameID: gameID, in: ctx), 648 info.scope == scopeValue 649 else { return false } 650 651 // The zone has already been confirmed missing server-side (see 652 // `applyZoneOrphaning`). Re-querying it just fails with `.zoneNotFound` 653 // (CKError 26) every time the revoked puzzle appears, leaving the 654 // diagnostics `Last Error` stuck on "Zone does not exist". Report the 655 // freshen as handled so the caller skips the full-DB `fetchChanges` 656 // fallback too — there is nothing left to converge. 657 guard !info.isAccessRevoked else { 658 await trace( 659 "\(label) game catch-up: \(gameID.uuidString.prefix(8)) skipped (access revoked)" 660 ) 661 return true 662 } 663 664 let checkpointKey = "\(scopeValue):\(gameID.uuidString)" 665 let since = liveQueryCheckpoints[checkpointKey]? 666 .addingTimeInterval(-liveQueryCheckpointOverlap) 667 let gameRecordID = CKRecord.ID( 668 recordName: RecordSerializer.recordName(forGameID: gameID), 669 zoneID: info.zoneID 670 ) 671 672 let gameResults: [CKRecord.ID: Result<CKRecord, Error>] 673 let moves: [CKRecord] 674 let players: [CKRecord] 675 do { 676 async let gameResultsTask = database.records( 677 for: [gameRecordID], 678 desiredKeys: RecordSerializer.gameDesiredKeys 679 ) 680 async let movesTask = queryLiveRecords( 681 type: "Moves", 682 database: database, 683 zoneID: info.zoneID, 684 since: since, 685 desiredKeys: RecordSerializer.movesDesiredKeys 686 ) 687 async let playersTask = queryLiveRecords( 688 type: "Player", 689 database: database, 690 zoneID: info.zoneID, 691 since: since, 692 desiredKeys: RecordSerializer.playerDesiredKeys 693 ) 694 (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) 695 } catch { 696 if scope == .private, 697 !info.isCloudConfirmed, 698 isZoneNotFoundError(error) { 699 await trace( 700 "\(label) game catch-up: \(gameID.uuidString.prefix(8)) " + 701 "skipped (zone pending creation)" 702 ) 703 return true 704 } 705 throw error 706 } 707 708 var records = moves + players 709 let gameCount: Int 710 if case .success(let record)? = gameResults[gameRecordID] { 711 records.append(record) 712 gameCount = 1 713 } else { 714 gameCount = 0 715 } 716 717 if let latestModification = records.compactMap(\.modificationDate).max() { 718 setLiveQueryCheckpoint( 719 latestModification, 720 scopeValue: scopeValue, 721 gameID: gameID 722 ) 723 } 724 725 await applyDirectRecordZoneChanges( 726 records: records, 727 deletions: [], 728 scopeValue: scopeValue 729 ) 730 await trace( 731 "\(label) game catch-up: \(gameID.uuidString.prefix(8)), " + 732 "game=\(gameCount), moves=\(moves.count), players=\(players.count)" 733 ) 734 return true 735 } 736 737 /// Pulls a just-accepted shared game by the zone ID CloudKit returned in 738 /// the share metadata. This is the latency-sensitive join path: the game 739 /// is not known locally yet, so `fetchGameDirect(scope:gameID:)` cannot 740 /// find its zone, and a full shared-zone discovery would query every 741 /// unknown shared zone before opening the one the user just tapped. 742 /// Pass `onlyGame: true` to fetch just the Game record. The join poll's 743 /// playability gate reads only `puzzleSource`, so its backstop re-fetches 744 /// don't need the two full-zone Moves/Player queries — the Puzzle Grid 745 /// re-fetches those itself on open. A Game-only pass also leaves the 746 /// live-query checkpoint untouched, since it hasn't comprehensively read the 747 /// zone's Moves/Players and must not let a later `since:` query skip them. 748 @discardableResult 749 func fetchAcceptedSharedGameDirect( 750 gameID: UUID, 751 zoneID: CKRecordZone.ID, 752 onlyGame: Bool = false 753 ) async throws -> Bool { 754 let database = container.sharedCloudDatabase 755 let gameRecordID = CKRecord.ID( 756 recordName: RecordSerializer.recordName(forGameID: gameID), 757 zoneID: zoneID 758 ) 759 760 let gameResults: [CKRecord.ID: Result<CKRecord, Error>] 761 let moves: [CKRecord] 762 let players: [CKRecord] 763 do { 764 async let gameResultsTask = database.records( 765 for: [gameRecordID], 766 desiredKeys: RecordSerializer.gameDesiredKeys 767 ) 768 async let movesTask = onlyGame ? [] : queryLiveRecords( 769 type: "Moves", 770 database: database, 771 zoneID: zoneID, 772 since: nil, 773 desiredKeys: RecordSerializer.movesDesiredKeys 774 ) 775 async let playersTask = onlyGame ? [] : queryLiveRecords( 776 type: "Player", 777 database: database, 778 zoneID: zoneID, 779 since: nil, 780 desiredKeys: RecordSerializer.playerDesiredKeys 781 ) 782 (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) 783 } catch { 784 if isZoneNotFoundError(error) { return false } 785 throw error 786 } 787 788 guard case .success(let game)? = gameResults[gameRecordID] else { 789 return false 790 } 791 792 let records = moves + players + [game] 793 // Only advance the checkpoint when this pass actually read the zone's 794 // Moves/Players; a Game-only backstop hasn't, so leaving it alone keeps 795 // a later `fetchGameDirect(since:)` from skipping unfetched moves. 796 if !onlyGame, 797 let latestModification = records.compactMap(\.modificationDate).max() { 798 setLiveQueryCheckpoint(latestModification, scopeValue: 1, gameID: gameID) 799 } 800 801 await applyDirectRecordZoneChanges( 802 records: records, 803 deletions: [], 804 scopeValue: 1 805 ) 806 await trace( 807 "shared accepted-game fetch: \(gameID.uuidString.prefix(8)), " + 808 (onlyGame 809 ? "game=1 (game-only backstop)" 810 : "game=1, moves=\(moves.count), players=\(players.count)") 811 ) 812 return true 813 } 814 815 nonisolated func isZoneNotFoundError(_ error: Error) -> Bool { 816 let nsError = error as NSError 817 return nsError.domain == CKErrorDomain && 818 nsError.code == CKError.zoneNotFound.rawValue 819 } 820 821 /// Background-push catch-up for library freshness. Intentionally skips 822 /// Player records because the immediate background session scan already 823 /// covers presence. 824 /// The delayed caller exists to catch the common ordering where a cursor 825 /// save triggers the silent push before the corresponding Moves record is 826 /// visible in CloudKit. 827 /// 828 /// Returns the number of Moves records fetched. Game records are always 829 /// fetched for metadata freshness, but delayed push catch-up uses the 830 /// Moves count to decide whether a later safety pass is still useful. 831 @discardableResult 832 func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int { 833 let database: CKDatabase 834 let scopeValue: Int16 835 let label: String 836 switch scope { 837 case .private: 838 database = container.privateCloudDatabase 839 scopeValue = 0 840 label = "private" 841 case .shared: 842 database = container.sharedCloudDatabase 843 scopeValue = 1 844 label = "shared" 845 case .public: 846 return 0 847 @unknown default: 848 return 0 849 } 850 851 let ctx = persistence.container.newBackgroundContext() 852 let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) 853 guard !zones.isEmpty else { 854 await trace("\(label) game/moves catch-up: no incomplete zones") 855 return 0 856 } 857 858 struct PerZoneGameMoves: Sendable { 859 let records: [CKRecord] 860 let gameCount: Int 861 let moveCount: Int 862 let orphanedZone: CKRecordZone.ID? 863 } 864 let perZone = await withTaskGroup(of: PerZoneGameMoves.self) { group in 865 for zone in zones { 866 group.addTask { [weak self] in 867 guard let self else { 868 return PerZoneGameMoves( 869 records: [], 870 gameCount: 0, 871 moveCount: 0, 872 orphanedZone: nil 873 ) 874 } 875 do { 876 let checkpointKey = "\(scopeValue):\(zone.gameID.uuidString)" 877 let since = await self.liveQueryCheckpoints[checkpointKey]? 878 .addingTimeInterval(-self.liveQueryCheckpointOverlap) 879 let gameRecordID = CKRecord.ID( 880 recordName: RecordSerializer.recordName(forGameID: zone.gameID), 881 zoneID: zone.zoneID 882 ) 883 async let gameResultsTask = database.records( 884 for: [gameRecordID], 885 desiredKeys: RecordSerializer.gameDesiredKeys 886 ) 887 async let movesTask = self.queryLiveRecords( 888 type: "Moves", 889 database: database, 890 zoneID: zone.zoneID, 891 since: since, 892 desiredKeys: RecordSerializer.movesDesiredKeys 893 ) 894 let (gameResults, moves) = try await (gameResultsTask, movesTask) 895 896 var records = moves 897 let gameCount: Int 898 if case .success(let record)? = gameResults[gameRecordID] { 899 records.append(record) 900 gameCount = 1 901 } else { 902 gameCount = 0 903 } 904 905 if let latestModification = records.compactMap(\.modificationDate).max() { 906 await self.setLiveQueryCheckpoint( 907 latestModification, 908 scopeValue: scopeValue, 909 gameID: zone.gameID 910 ) 911 } 912 913 return PerZoneGameMoves( 914 records: records, 915 gameCount: gameCount, 916 moveCount: moves.count, 917 orphanedZone: nil 918 ) 919 } catch { 920 let orphan: CKRecordZone.ID? 921 if scope == .shared, 922 self.isInvalidSharedZoneOwnerError(error as NSError) { 923 orphan = zone.zoneID 924 } else { 925 orphan = nil 926 } 927 await self.trace( 928 "\(label) game/moves catch-up: zone \(zone.zoneID.zoneName) failed: " + 929 "\(error.localizedDescription)" 930 ) 931 return PerZoneGameMoves( 932 records: [], 933 gameCount: 0, 934 moveCount: 0, 935 orphanedZone: orphan 936 ) 937 } 938 } 939 } 940 941 var all: [PerZoneGameMoves] = [] 942 for await result in group { 943 all.append(result) 944 } 945 return all 946 } 947 948 let records = perZone.flatMap(\.records) 949 await applyDirectRecordZoneChanges( 950 records: records, 951 deletions: [], 952 scopeValue: scopeValue 953 ) 954 955 let orphans = Set(perZone.compactMap(\.orphanedZone)) 956 if !orphans.isEmpty { 957 await applyZoneOrphaning(orphans, isPrivate: scope == .private) 958 } 959 960 let gameCount = perZone.reduce(0) { $0 + $1.gameCount } 961 let moveCount = perZone.reduce(0) { $0 + $1.moveCount } 962 await trace( 963 "\(label) game/moves catch-up: zones=\(zones.count), " + 964 "game=\(gameCount), moves=\(moveCount)" 965 ) 966 return moveCount 967 } 968 969 private func queryLiveRecords( 970 type: CKRecord.RecordType, 971 database: CKDatabase, 972 zoneID: CKRecordZone.ID, 973 since: Date?, 974 desiredKeys: [CKRecord.FieldKey] 975 ) async throws -> [CKRecord] { 976 let since = since ?? Date(timeIntervalSince1970: 0) 977 return try await queryRecords( 978 type: type, 979 database: database, 980 zoneID: zoneID, 981 predicate: NSPredicate(format: "modificationDate > %@", since as NSDate), 982 desiredKeys: desiredKeys 983 ) 984 } 985 986 func queryRecords( 987 type: CKRecord.RecordType, 988 database: CKDatabase, 989 zoneID: CKRecordZone.ID, 990 predicate: NSPredicate, 991 desiredKeys: [CKRecord.FieldKey] 992 ) async throws -> [CKRecord] { 993 let query = CKQuery(recordType: type, predicate: predicate) 994 995 var records: [CKRecord] = [] 996 var result = try await database.records( 997 matching: query, 998 inZoneWith: zoneID, 999 desiredKeys: desiredKeys, 1000 resultsLimit: CKQueryOperation.maximumResults 1001 ) 1002 records.append(contentsOf: result.matchResults.compactMap { _, recordResult in 1003 try? recordResult.get() 1004 }) 1005 1006 while let cursor = result.queryCursor { 1007 result = try await database.records( 1008 continuingMatchFrom: cursor, 1009 desiredKeys: desiredKeys, 1010 resultsLimit: CKQueryOperation.maximumResults 1011 ) 1012 records.append(contentsOf: result.matchResults.compactMap { _, recordResult in 1013 try? recordResult.get() 1014 }) 1015 } 1016 return records 1017 } 1018 1019 private func setLiveQueryCheckpoint( 1020 _ date: Date, 1021 scopeValue: Int16, 1022 gameID: UUID 1023 ) { 1024 liveQueryCheckpoints["\(scopeValue):\(gameID.uuidString)"] = date 1025 } 1026 1027 func deleteRecords( 1028 withIDs recordIDs: [CKRecord.ID], 1029 in database: CKDatabase 1030 ) async throws { 1031 guard !recordIDs.isEmpty else { return } 1032 let batchSize = 200 1033 var index = recordIDs.startIndex 1034 while index < recordIDs.endIndex { 1035 let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex) 1036 ?? recordIDs.endIndex 1037 let batch = Array(recordIDs[index..<end]) 1038 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 1039 let op = CKModifyRecordsOperation( 1040 recordsToSave: nil, 1041 recordIDsToDelete: batch 1042 ) 1043 op.qualityOfService = .utility 1044 op.modifyRecordsResultBlock = { result in 1045 cont.resume(with: result) 1046 } 1047 database.add(op) 1048 } 1049 index = end 1050 } 1051 } 1052 1053 /// Fetches every device's uploaded `Journal` record for a finished game, 1054 /// together with the set of devices that wrote grid state (from the `Moves` 1055 /// record names), so the caller can gate replay on completeness. A plain 1056 /// zone-scoped `CKQuery` — it reads on demand and does **not** disturb the 1057 /// sync engine's change token (inbound `Journal` records stay ignored in the 1058 /// delegate path). Returns `nil` when the game's zone isn't known locally or 1059 /// access has been revoked; the caller surfaces that as `.unavailable`. 1060 func fetchReplay(forGameID gameID: UUID) async throws -> JournalReplayFetch? { 1061 let ctx = persistence.container.newBackgroundContext() 1062 guard let info = zoneInfo(forGameID: gameID, in: ctx), !info.isAccessRevoked else { 1063 return nil 1064 } 1065 let database = info.scope == 1 1066 ? container.sharedCloudDatabase 1067 : container.privateCloudDatabase 1068 1069 // Expected device set: every device that wrote grid state owns a Moves 1070 // record named `moves-<game>-<author>-<device>`. Keys only. 1071 let movesRecords = try await queryRecords( 1072 type: "Moves", 1073 database: database, 1074 zoneID: info.zoneID, 1075 predicate: NSPredicate(value: true), 1076 desiredKeys: [] 1077 ) 1078 var expected = Set<JournalDeviceKey>() 1079 for record in movesRecords { 1080 if let (_, authorID, deviceID) = 1081 RecordSerializer.parseMovesRecordName(record.recordID.recordName) { 1082 expected.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID)) 1083 } 1084 } 1085 1086 // Present journals: decode each device's `entries` asset into its log. 1087 let journalRecords = try await queryRecords( 1088 type: "Journal", 1089 database: database, 1090 zoneID: info.zoneID, 1091 predicate: NSPredicate(value: true), 1092 desiredKeys: ["entries"] 1093 ) 1094 var journals: [DeviceJournal] = [] 1095 for record in journalRecords { 1096 guard let (_, authorID, deviceID) = 1097 RecordSerializer.parseJournalRecordName(record.recordID.recordName), 1098 let asset = record["entries"] as? CKAsset, 1099 let url = asset.fileURL 1100 else { continue } 1101 do { 1102 let entries = try JournalCodec.decode(Data(contentsOf: url)) 1103 journals.append( 1104 DeviceJournal( 1105 key: JournalDeviceKey(authorID: authorID, deviceID: deviceID), 1106 entries: entries 1107 ) 1108 ) 1109 } catch { 1110 await trace( 1111 "fetchReplay: journal decode failed for " + 1112 "\(record.recordID.recordName): \(describe(error))" 1113 ) 1114 } 1115 } 1116 await trace( 1117 "fetchReplay \(gameID.uuidString.prefix(8)): scope=\(info.scope) " + 1118 "movesRecords=\(movesRecords.count) expectedDevices=\(expected.count) " + 1119 "journalRecords=\(journalRecords.count) " + 1120 "journalEntryCounts=[\(journals.map { String($0.entries.count) }.joined(separator: ","))]" 1121 ) 1122 return JournalReplayFetch(journals: journals, expectedDevices: expected) 1123 } 1124 }