crossmate

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

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 }