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