crossmate

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

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 }