crossmate

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

GameArchiver.swift (53790B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Shares one asynchronous operation among overlapping callers for the same
      6 /// key. Main-actor isolation makes the lookup-and-install atomic even though
      7 /// the operation itself can suspend.
      8 @MainActor
      9 final class AsyncTaskCoalescer<Key: Hashable, Value: Sendable> {
     10     private var tasks: [Key: Task<Value, Never>] = [:]
     11 
     12     func run(
     13         for key: Key,
     14         operation: @MainActor @Sendable @escaping () async -> Value
     15     ) async -> Value {
     16         if let task = tasks[key] {
     17             return await task.value
     18         }
     19 
     20         let task = Task { @MainActor in
     21             await operation()
     22         }
     23         tasks[key] = task
     24         let value = await task.value
     25         tasks[key] = nil
     26         return value
     27     }
     28 
     29     /// Waits only when work for `key` is already under way. Used by cleanup
     30     /// that must not race the materialization it follows.
     31     func waitForCurrentTask(for key: Key) async {
     32         guard let task = tasks[key] else { return }
     33         _ = await task.value
     34     }
     35 }
     36 
     37 enum CompletedMetadataPageWalker {
     38     struct Result<Item, Cursor> {
     39         let selected: [Item]
     40         let buffered: [Item]
     41         let cursor: Cursor?
     42     }
     43 
     44     /// Walks ordered metadata pages until the first item outside the initial
     45     /// date window. Passing the returned cursor into every subsequent fetch is
     46     /// the invariant that prevents page one from being re-read indefinitely.
     47     @MainActor
     48     static func recent<Item, Cursor>(
     49         cutoff: Date,
     50         completedAt: (Item) -> Date,
     51         fetch: @MainActor (Cursor?) async throws -> (
     52             records: [Item],
     53             cursor: Cursor?
     54         )
     55     ) async rethrows -> Result<Item, Cursor> {
     56         var cursor: Cursor?
     57         var selected: [Item] = []
     58         var buffered: [Item] = []
     59         repeat {
     60             let page = try await fetch(cursor)
     61             cursor = page.cursor
     62             if let firstOlder = page.records.firstIndex(where: {
     63                 completedAt($0) < cutoff
     64             }) {
     65                 selected.append(contentsOf: page.records[..<firstOlder])
     66                 buffered.append(contentsOf: page.records[firstOlder...])
     67                 break
     68             }
     69             selected.append(contentsOf: page.records)
     70         } while cursor != nil
     71         return Result(selected: selected, buffered: buffered, cursor: cursor)
     72     }
     73 }
     74 
     75 /// Compacts completed games into the account's private archive zone and retires
     76 /// their multi-record live zones once every participant has archived, or once
     77 /// the hard retention deadline expires.
     78 @MainActor
     79 final class GameArchiver {
     80     nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60
     81     nonisolated static let completedPageSize = 7
     82 
     83     /// Payload reads allowed per ledger backfill run. A typical Chronicle is
     84     /// tens of kilobytes, so this is a modest download for a library that has
     85     /// never been indexed, and a long history simply converges over a few
     86     /// launches instead of one long run.
     87     nonisolated static let chronicleLedgerBackfillLimit = 50
     88 
     89     struct CompletedPage: Sendable {
     90         let oldestCompletedAt: Date?
     91         let hasMore: Bool
     92     }
     93 
     94     private enum CompletedSource {
     95         case chronicle(CKRecord.ID)
     96         case game(
     97             gameID: UUID,
     98             zoneID: CKRecordZone.ID,
     99             scope: DatabaseScope
    100         )
    101     }
    102 
    103     private struct CompletedMetadata {
    104         let originalGameID: UUID
    105         let completedAt: Date
    106         let source: CompletedSource
    107 
    108         var isLiveGame: Bool {
    109             if case .game = source { return true }
    110             return false
    111         }
    112     }
    113 
    114     private struct LocalGame {
    115         let snapshot: Archive.Snapshot
    116         let databaseScope: DatabaseScope
    117         let liveZoneID: CKRecordZone.ID
    118         let isShared: Bool
    119         /// nil means the owner has not received an authoritative CKShare roster
    120         /// yet. An empty set is a known solo/no-participant game.
    121         let acceptedParticipants: Set<String>?
    122         let archiveAcknowledgedAt: Date?
    123     }
    124 
    125     private struct StoredArchive {
    126         let payload: Archive.Payload
    127         let isLegacy: Bool
    128     }
    129 
    130     private let container: CKContainer
    131     private let persistence: PersistenceController
    132     private let syncEngine: SyncEngine
    133     private let syncMonitor: SyncMonitor?
    134     private let eventLog: EventLog?
    135     private let localIdentity: () -> (authorID: String, playerName: String)?
    136     private let localDefaults: UserDefaults
    137     private let ubiquitousStore: NSUbiquitousKeyValueStore?
    138     private var ensuredArchiveZone = false
    139     private var chronicleCursor: CKQueryOperation.Cursor?
    140     private var bufferedCompleted: [CompletedMetadata] = []
    141     private let revokedPromotionCoalescer = AsyncTaskCoalescer<UUID, UUID?>()
    142     private let revokedRetirementCoalescer = AsyncTaskCoalescer<UUID, Bool>()
    143 
    144     init(
    145         container: CKContainer,
    146         persistence: PersistenceController,
    147         syncEngine: SyncEngine,
    148         syncMonitor: SyncMonitor? = nil,
    149         eventLog: EventLog? = nil,
    150         localIdentity: @escaping () -> (authorID: String, playerName: String)? = { nil },
    151         localDefaults: UserDefaults = .standard,
    152         ubiquitousStore: NSUbiquitousKeyValueStore? = .default
    153     ) {
    154         self.container = container
    155         self.persistence = persistence
    156         self.syncEngine = syncEngine
    157         self.syncMonitor = syncMonitor
    158         self.eventLog = eventLog
    159         self.localIdentity = localIdentity
    160         self.localDefaults = localDefaults
    161         self.ubiquitousStore = ubiquitousStore
    162     }
    163 
    164     // MARK: - Reconciliation
    165 
    166     /// Immediate completion path. It writes/refreshes the archive and emits a
    167     /// participant acknowledgement, but leaves zone retirement to the cold-
    168     /// launch reconciliation path so an open Success Panel is never replaced
    169     /// underneath the user.
    170     func archiveIfNeeded(gameID: UUID) async {
    171         guard let graceStart = await accountGraceStart() else { return }
    172         _ = await reconcileArchive(gameID: gameID, graceStart: graceStart)
    173     }
    174 
    175     /// Cold-launch backstop for new completions, migrations, acknowledgements,
    176     /// and owner-side zone retirement.
    177     func reconcileUnarchived() async {
    178         guard let graceStart = await accountGraceStart() else { return }
    179         await migrateMaterializedLegacyArchives()
    180 
    181         let ctx = persistence.container.newBackgroundContext()
    182         let ids: [UUID] = await ctx.perform {
    183             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    184             req.predicate = NSPredicate(
    185                 format: "completedAt != nil AND isAccessRevoked == NO " +
    186                     "AND isSupersededByChronicle == NO AND ckRecordName BEGINSWITH %@",
    187                 "game-"
    188             )
    189             return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
    190         }
    191         for id in ids {
    192             guard let result = await reconcileArchive(gameID: id, graceStart: graceStart),
    193                   result.local.databaseScope == .private
    194             else { continue }
    195             await retireOwnedGameIfEligible(
    196                 result.local,
    197                 snapshot: result.snapshot,
    198                 archiveComplete: result.complete,
    199                 graceStart: graceStart
    200             )
    201         }
    202     }
    203 
    204     /// Whether the Chronicle in CloudKit still says what this pass concluded.
    205     ///
    206     /// The journal comparison is deliberately limited to `.available`: only a
    207     /// complete Chronicle embeds journals, so a provisional one is always
    208     /// stored with an empty journal. Comparing that against the merged local
    209     /// history would report "missing" on every pass and re-upload the whole
    210     /// payload — a fetch, a replay query, and a compressed asset — for the
    211     /// entire multi-day waiting window, without ever changing what's stored.
    212     nonisolated static func chronicleNeedsWrite(
    213         stored: (
    214             isLegacy: Bool,
    215             formatVersion: Int,
    216             replayState: Archive.ReplayState,
    217             journalKeys: Set<JournalDeviceKey>
    218         )?,
    219         replayState: Archive.ReplayState,
    220         presentJournalKeys: Set<JournalDeviceKey>
    221     ) -> Bool {
    222         guard let stored else { return true }
    223         if stored.isLegacy { return true }
    224         if stored.formatVersion != Archive.currentPayloadFormatVersion { return true }
    225         if stored.replayState != replayState { return true }
    226         return replayState == .available
    227             && !presentJournalKeys.isSubset(of: stored.journalKeys)
    228     }
    229 
    230     nonisolated static func hasArchiveRetryExpired(
    231         completedAt: Date,
    232         graceStart: Date = .distantPast,
    233         now: Date = Date()
    234     ) -> Bool {
    235         now >= max(completedAt, graceStart).addingTimeInterval(archiveRetryWindow)
    236     }
    237 
    238     private func localGame(gameID: UUID) async -> LocalGame? {
    239         let ctx = persistence.container.newBackgroundContext()
    240         return await ctx.perform {
    241             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    242             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    243             req.fetchLimit = 1
    244             guard let entity = try? ctx.fetch(req).first,
    245                   entity.completedAt != nil,
    246                   !entity.isAccessRevoked,
    247                   // A row its Chronicle already stands for has finished with
    248                   // this path: the archive is written, the zone retirement is
    249                   // enqueued, and all that remains is deleting the row once no
    250                   // `PuzzleView` still holds it.
    251                   !entity.isSupersededByChronicle,
    252                   entity.ckRecordName?.hasPrefix("game-") == true,
    253                   let snapshot = Archive.snapshot(forGameID: gameID, in: ctx)
    254             else { return nil }
    255 
    256             let scope = DatabaseScope(entityValue: entity.databaseScope)
    257             let isShared = entity.ckShareRecordName != nil || scope == .shared
    258             let participants: Set<String>?
    259             if !isShared {
    260                 participants = []
    261                 if entity.archiveParticipants == nil { entity.archiveParticipants = "" }
    262             } else if let encoded = entity.archiveParticipants {
    263                 participants = Set(encoded.split(separator: ",").map(String.init))
    264             } else if let encoded = entity.shareParticipants {
    265                 entity.archiveParticipants = encoded
    266                 participants = Set(encoded.split(separator: ",").map(String.init))
    267             } else {
    268                 participants = nil
    269             }
    270             if ctx.hasChanges { try? ctx.save() }
    271             return LocalGame(
    272                 snapshot: snapshot,
    273                 databaseScope: scope,
    274                 liveZoneID: CKRecordZone.ID(
    275                     zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)",
    276                     ownerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    277                 ),
    278                 isShared: isShared,
    279                 acceptedParticipants: participants,
    280                 archiveAcknowledgedAt: entity.archiveAcknowledgedAt
    281             )
    282         }
    283     }
    284 
    285     private func reconcileArchive(
    286         gameID: UUID,
    287         graceStart: Date
    288     ) async -> (local: LocalGame, snapshot: Archive.Snapshot, complete: Bool)? {
    289         guard let local = await localGame(gameID: gameID) else { return nil }
    290 
    291         let stored = await fetchArchive(originalGameID: gameID)
    292         // A complete compact Chronicle is authoritative: it was written only
    293         // after every expected device journal was present. Re-reading the live
    294         // replay cannot improve it and makes overlapping migration backstops
    295         // download the same Moves and Journal assets repeatedly.
    296         let storedComplete = stored.map {
    297             !$0.isLegacy && $0.payload.replayAvailable
    298         } ?? false
    299         let canSkipReplayFetch = storedComplete
    300             && stored?.payload.formatVersion == Archive.currentPayloadFormatVersion
    301         let fetch = canSkipReplayFetch
    302             ? nil
    303             : try? await syncEngine.fetchReplay(forGameID: gameID)
    304         var snapshot = local.snapshot
    305         if let stored { snapshot = Archive.merging(snapshot, peerJournals: stored.payload.journal) }
    306         if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) }
    307 
    308         let present = Set(snapshot.journal.map(\.key))
    309         let fetchedMissing = fetch.map {
    310             $0.expectedDevices.subtracting(present).count
    311         }
    312         let fetchedComplete = fetchedMissing == 0
    313         // Only a compact payload's explicit flag is authoritative. Legacy
    314         // archives used archivedAt for both completeness and a timed best-effort
    315         // fallback, so they must be checked against the still-live zone again.
    316         let complete = storedComplete || fetchedComplete
    317         let replayState: Archive.ReplayState
    318         if complete {
    319             replayState = .available
    320         } else if case .waiting(let storedMissing) = stored?.payload.replayState {
    321             replayState = .waiting(missing: fetchedMissing ?? storedMissing)
    322         } else {
    323             // A failed first fetch cannot determine the exact count yet, but it
    324             // is still retryable rather than a retention fallback.
    325             replayState = .waiting(missing: fetchedMissing ?? 1)
    326         }
    327 
    328         let needsWrite = Self.chronicleNeedsWrite(
    329             stored: stored.map {
    330                 (
    331                     isLegacy: $0.isLegacy,
    332                     formatVersion: $0.payload.formatVersion,
    333                     replayState: $0.payload.replayState,
    334                     journalKeys: Set($0.payload.journal.map(\.key))
    335                 )
    336             },
    337             replayState: replayState,
    338             presentJournalKeys: present
    339         )
    340         if needsWrite {
    341             guard await write(snapshot, replayState: replayState) else { return nil }
    342         }
    343 
    344         if stored?.isLegacy == true {
    345             await syncEngine.enqueueDeleteLegacyArchiveZone(
    346                 Archive.legacyZoneID(forOriginalGameID: gameID)
    347             )
    348         }
    349 
    350         if complete {
    351             await markArchived(originalGameID: gameID)
    352             if local.databaseScope == .shared, local.archiveAcknowledgedAt == nil {
    353                 await acknowledgeChronicle(gameID: gameID)
    354             }
    355         }
    356         return (local, snapshot, complete)
    357     }
    358 
    359     // MARK: - Retirement
    360 
    361     private func retireOwnedGameIfEligible(
    362         _ local: LocalGame,
    363         snapshot: Archive.Snapshot,
    364         archiveComplete: Bool,
    365         graceStart: Date
    366     ) async {
    367         let expired = Self.hasArchiveRetryExpired(
    368             completedAt: snapshot.completedAt,
    369             graceStart: graceStart
    370         )
    371 
    372         var quorum = false
    373         if archiveComplete, let expected = local.acceptedParticipants {
    374             if expected.isEmpty {
    375                 quorum = true
    376             } else if let acknowledged = try? await syncEngine.fetchChronicleAcknowledgements(
    377                 forGameID: snapshot.originalGameID
    378             ) {
    379                 quorum = expected.isSubset(of: acknowledged)
    380             }
    381         }
    382         guard quorum || expired else { return }
    383 
    384         let keepReplay = quorum && archiveComplete
    385         if !keepReplay {
    386             syncMonitor?.note(
    387                 "archive \(snapshot.originalGameID.uuidString.prefix(8)): " +
    388                 "retention deadline reached; retiring without replay"
    389             )
    390             guard await write(snapshot, replayState: .unavailable) else { return }
    391         }
    392         guard let chronicleID = await promoteOwnedBeforeRetirement(
    393             snapshot,
    394             replayState: keepReplay ? .available : .unavailable
    395         ) else {
    396             return
    397         }
    398         // The puzzle may be open on this device — retirement is decided by a
    399         // cold-launch reconciliation pass that has no idea what is on screen.
    400         // Hand it over rather than letting the row vanish underneath: the
    401         // Chronicle is the same puzzle, so it takes the live game's place at
    402         // the top of the stack. A no-op when no stack is showing it.
    403         NotificationNavigationBroker.shared.replaceOpenGame(
    404             snapshot.originalGameID,
    405             with: chronicleID
    406         )
    407         await syncEngine.enqueueRetireOwnedGameZone(local.liveZoneID)
    408     }
    409 
    410     /// Materializes the Chronicle that replaces an owned game about to have its
    411     /// zone retired, returning the Chronicle's game id.
    412     ///
    413     /// The live row is taken out of the library but *not* deleted: deleting it
    414     /// under a mounted `PuzzleView` would strand that view on a dead
    415     /// `GameEntity`. `retireSupersededLiveRow` deletes it once no view can
    416     /// still hold it, exactly as the participant-side handover does.
    417     private func promoteOwnedBeforeRetirement(
    418         _ snapshot: Archive.Snapshot,
    419         replayState: Archive.ReplayState
    420     ) async -> UUID? {
    421         let ctx = persistence.container.newBackgroundContext()
    422         return await ctx.perform {
    423             let payload = Archive.payload(from: snapshot, replayState: replayState)
    424             guard let chronicle = Archive.materialize(payload, in: ctx),
    425                   let chronicleID = chronicle.id
    426             else { return nil }
    427             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    428             req.predicate = NSPredicate(
    429                 format: "id == %@", snapshot.originalGameID as CVarArg
    430             )
    431             req.fetchLimit = 1
    432             if let original = try? ctx.fetch(req).first {
    433                 original.isSupersededByChronicle = true
    434             }
    435             do {
    436                 if ctx.hasChanges { try ctx.save() }
    437                 return chronicleID
    438             } catch {
    439                 return nil
    440             }
    441         }
    442     }
    443 
    444     // MARK: - Acknowledgement
    445 
    446     private func acknowledgeChronicle(gameID: UUID) async {
    447         guard let identity = localIdentity() else { return }
    448         let enqueued = await syncEngine.enqueuePing(
    449             kind: .chronicled,
    450             gameID: gameID,
    451             authorID: identity.authorID,
    452             playerName: identity.playerName
    453         )
    454         guard enqueued else { return }
    455         let ctx = persistence.container.newBackgroundContext()
    456         await ctx.perform {
    457             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    458             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    459             req.fetchLimit = 1
    460             guard let entity = try? ctx.fetch(req).first else { return }
    461             entity.archiveAcknowledgedAt = Date()
    462             try? ctx.save()
    463         }
    464     }
    465 
    466     private func markArchived(originalGameID: UUID) async {
    467         let ctx = persistence.container.newBackgroundContext()
    468         await ctx.perform {
    469             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    470             req.predicate = NSPredicate(format: "id == %@", originalGameID as CVarArg)
    471             req.fetchLimit = 1
    472             guard let entity = try? ctx.fetch(req).first else { return }
    473             if entity.archivedAt == nil { entity.archivedAt = Date() }
    474             entity.archiveGameID = Archive.archiveGameID(for: originalGameID)
    475             try? ctx.save()
    476         }
    477     }
    478 
    479     // MARK: - Restore / legacy migration
    480 
    481     /// Rebuilds a completed game after another owner device retired its live
    482     /// zone and the sync applier removed the local live row first, returning the
    483     /// Chronicle's game id so an open puzzle can be handed over to it.
    484     ///
    485     /// Unlike `promoteRevoked` this does not insist on a complete replay. That
    486     /// guard exists to keep a half-filled grid from being frozen while the user
    487     /// still has a revoked row to look at; here the live row is already gone, so
    488     /// whatever the account chronicled is the only representation left.
    489     @discardableResult
    490     func restoreRetired(gameID: UUID) async -> UUID? {
    491         guard let stored = await fetchArchive(originalGameID: gameID) else { return nil }
    492         let ctx = persistence.container.newBackgroundContext()
    493         return await ctx.perform {
    494             guard let chronicle = Archive.materialize(stored.payload, in: ctx),
    495                   let chronicleID = chronicle.id
    496             else { return nil }
    497             do {
    498                 if ctx.hasChanges { try ctx.save() }
    499                 return chronicleID
    500             } catch {
    501                 return nil
    502             }
    503         }
    504     }
    505 
    506     /// Sweeps every finished game whose live zone has gone, promoting a newly
    507     /// revoked one to its Chronicle, and returns every live row a Chronicle now
    508     /// stands for — the ones just promoted and any earlier handover still
    509     /// waiting on the puzzle that held it. Catches up a revocation the app never
    510     /// got to act on because it was terminated, one whose Chronicle could not be
    511     /// read at the time, and an owner-side retirement whose row outlived the
    512     /// puzzle it was handed over from.
    513     func sweepSupersededLiveGames() async -> [UUID] {
    514         let ctx = persistence.container.newBackgroundContext()
    515         let (revoked, superseded): ([UUID], [UUID]) = await ctx.perform {
    516             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    517             req.predicate = NSPredicate(
    518                 format: "completedAt != nil AND ckRecordName BEGINSWITH %@ " +
    519                     "AND (isAccessRevoked == YES OR isSupersededByChronicle == YES)",
    520                 "game-"
    521             )
    522             let rows = (try? ctx.fetch(req)) ?? []
    523             return (
    524                 rows.filter { !$0.isSupersededByChronicle }.compactMap(\.id),
    525                 rows.filter(\.isSupersededByChronicle).compactMap(\.id)
    526             )
    527         }
    528         // Already-superseded rows skip promotion: they have their Chronicle,
    529         // and re-materializing it would cost a CloudKit read per list opening.
    530         var retirable = superseded
    531         for id in revoked {
    532             if await promoteRevoked(gameID: id) != nil {
    533                 retirable.append(id)
    534             }
    535         }
    536         return retirable
    537     }
    538 
    539     /// Promotes a participant's private archive when the owner retires the live
    540     /// shared zone, returning the Chronicle's game id when the handover lands.
    541     ///
    542     /// Only a *complete* history earns the handover. The owner normally retires
    543     /// the zone on quorum — every participant having chronicled the game, which
    544     /// each only does once its own Chronicle saved complete — but it also retires
    545     /// unconditionally once `archiveRetryWindow` has passed since completion. A
    546     /// device that was away for that whole stretch can reach here holding a grid
    547     /// that never caught up, and `Archive.merging` fills a Chronicle's frozen
    548     /// cells from the local rows alone: peer journals extend the replay, never
    549     /// the board. Freezing that would present a half-filled grid as a finished
    550     /// puzzle, with the zone gone and no way back. Those stay revoked rows, a
    551     /// state the user is actually shown.
    552     ///
    553     /// Overlapping callers share one promotion. This matters when the revocation
    554     /// callback is suspended in CloudKit and the user returns to the Game List,
    555     /// whose catch-up sweep asks for the same promotion.
    556     ///
    557     /// Promotion never deletes the live row. It is hidden behind the Chronicle
    558     /// here, then retired separately once no mounted `PuzzleView` can still hold
    559     /// its `GameEntity`.
    560     @discardableResult
    561     func promoteRevoked(gameID: UUID) async -> UUID? {
    562         await revokedPromotionCoalescer.run(for: gameID) { [weak self] in
    563             guard let self else { return nil }
    564             return await self.performPromoteRevoked(gameID: gameID)
    565         }
    566     }
    567 
    568     private func performPromoteRevoked(gameID: UUID) async -> UUID? {
    569         // A game this device never saw finish is a genuine mid-play revocation,
    570         // whatever the account's Chronicle says; leave it as a revoked row.
    571         let ctx = persistence.container.newBackgroundContext()
    572         let isCompleted = await ctx.perform {
    573             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    574             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    575             req.fetchLimit = 1
    576             return (try? ctx.fetch(req).first)?.completedAt != nil
    577         }
    578         guard isCompleted else { return nil }
    579 
    580         // The stored Chronicle is the only witness worth trusting. An
    581         // acknowledgement would seem to be a second one, but a device that read
    582         // a *sibling* device's complete Chronicle acknowledges too, without its
    583         // own cells ever catching up — so it attests to the account's history,
    584         // not this row's. And an acknowledgement implies a complete Chronicle
    585         // was written (`reconcileArchive` bails before acking if the write
    586         // fails), so reading it back costs nothing but a retry when offline.
    587         guard let payload = await fetchArchive(originalGameID: gameID)?.payload,
    588               payload.replayState == .available
    589         else {
    590             syncMonitor?.note(
    591                 "archive \(gameID.uuidString.prefix(8)): no complete Chronicle to " +
    592                 "hand over to; staying revoked"
    593             )
    594             return nil
    595         }
    596 
    597         let promoteCtx = persistence.container.newBackgroundContext()
    598         return await promoteCtx.perform {
    599             guard let chronicle = Archive.materialize(payload, in: promoteCtx),
    600                   let chronicleID = chronicle.id
    601             else { return nil }
    602             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    603             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    604             req.fetchLimit = 1
    605             if let original = try? promoteCtx.fetch(req).first {
    606                 // Keep the row for any mounted view to finish with, but take it
    607                 // out of the library now: the Chronicle is already the visible
    608                 // representation, and they share a list identity.
    609                 original.isSupersededByChronicle = true
    610             }
    611             if promoteCtx.hasChanges {
    612                 do {
    613                     try promoteCtx.save()
    614                 } catch {
    615                     return nil
    616                 }
    617             }
    618             return chronicleID
    619         }
    620     }
    621 
    622     /// Deletes a live row its Chronicle has replaced — whether by a participant
    623     /// revocation or an owner-side zone retirement — once that Chronicle is
    624     /// durable and any in-flight promotion has finished. Calling this from
    625     /// `PuzzleView`'s disappearance is what makes deletion safe for an
    626     /// open-puzzle handover; off-screen callers can invoke it immediately after
    627     /// promotion.
    628     @discardableResult
    629     func retireSupersededLiveRow(gameID: UUID) async -> Bool {
    630         await revokedPromotionCoalescer.waitForCurrentTask(for: gameID)
    631         return await revokedRetirementCoalescer.run(for: gameID) { [weak self] in
    632             guard let self else { return false }
    633             return await self.performRetireSupersededLiveRow(gameID: gameID)
    634         }
    635     }
    636 
    637     private func performRetireSupersededLiveRow(gameID: UUID) async -> Bool {
    638         let ctx = persistence.container.newBackgroundContext()
    639         return await ctx.perform {
    640             let chronicleReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    641             chronicleReq.predicate = NSPredicate(
    642                 format: "id == %@ AND replayCacheComplete == YES",
    643                 Archive.archiveGameID(for: gameID) as CVarArg
    644             )
    645             chronicleReq.fetchLimit = 1
    646             guard (try? ctx.fetch(chronicleReq).first) != nil else { return false }
    647 
    648             let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    649             liveReq.predicate = NSPredicate(
    650                 format: "id == %@ AND ckRecordName BEGINSWITH %@ " +
    651                     "AND (isAccessRevoked == YES OR isSupersededByChronicle == YES)",
    652                 gameID as CVarArg,
    653                 "game-"
    654             )
    655             liveReq.fetchLimit = 1
    656             guard let live = try? ctx.fetch(liveReq).first else { return true }
    657             ctx.delete(live)
    658             do {
    659                 try ctx.save()
    660                 return true
    661             } catch {
    662                 return false
    663             }
    664         }
    665     }
    666 
    667     private func migrateMaterializedLegacyArchives() async {
    668         let ctx = persistence.container.newBackgroundContext()
    669         let candidates: [(localID: UUID, originalID: UUID)] = await ctx.perform {
    670             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    671             req.predicate = NSPredicate(format: "ckRecordName BEGINSWITH %@", "archive-")
    672             return ((try? ctx.fetch(req)) ?? []).compactMap { entity in
    673                 guard let localID = entity.id,
    674                       entity.ckZoneName?.hasPrefix("archive-") == true,
    675                       let name = entity.ckRecordName,
    676                       let originalID = Archive.originalGameID(fromName: name)
    677                 else { return nil }
    678                 return (localID, originalID)
    679             }
    680         }
    681         for candidate in candidates {
    682             let snapshot: Archive.Snapshot? = await ctx.perform {
    683                 Archive.snapshot(
    684                     forGameID: candidate.localID,
    685                     originalGameID: candidate.originalID,
    686                     in: ctx
    687                 )
    688             }
    689             guard let snapshot,
    690                   await write(snapshot, replayState: .available)
    691             else { continue }
    692             await ctx.perform {
    693                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    694                 req.predicate = NSPredicate(format: "id == %@", candidate.localID as CVarArg)
    695                 req.fetchLimit = 1
    696                 if let entity = try? ctx.fetch(req).first {
    697                     entity.ckRecordName = Archive.recordName(
    698                         forOriginalGameID: candidate.originalID
    699                     )
    700                     entity.ckZoneName = Archive.zoneName
    701                     try? ctx.save()
    702                 }
    703             }
    704             await syncEngine.enqueueDeleteLegacyArchiveZone(
    705                 Archive.legacyZoneID(forOriginalGameID: candidate.originalID)
    706             )
    707         }
    708     }
    709 
    710     // MARK: - CloudKit archive I/O
    711 
    712     /// Starts a fresh descending scan across Chronicle and completed Game
    713     /// records. Only scalar metadata is read while locating the initial
    714     /// seven-day window; full zones/assets are fetched for visible records.
    715     func loadRecentCompleted(since cutoff: Date) async -> CompletedPage {
    716         chronicleCursor = nil
    717         bufferedCompleted = []
    718 
    719         do {
    720             let walk = try await CompletedMetadataPageWalker.recent(
    721                 cutoff: cutoff,
    722                 completedAt: \.completedAt,
    723                 fetch: fetchChronicleMetadataPage(continuing:)
    724             )
    725             chronicleCursor = walk.cursor
    726             bufferedCompleted.append(contentsOf: walk.buffered)
    727 
    728             let gameMetadata = try await fetchCompletedGameMetadata()
    729             let recentGames = gameMetadata.filter { $0.completedAt >= cutoff }
    730             bufferedCompleted.append(
    731                 contentsOf: gameMetadata.filter { $0.completedAt < cutoff }
    732             )
    733             bufferedCompleted = mergedMetadata(bufferedCompleted)
    734 
    735             let selected = mergedMetadata(walk.selected + recentGames)
    736             await hydrateCompleted(selected)
    737             await trimMaterializedChronicles(before: cutoff)
    738             return CompletedPage(
    739                 oldestCompletedAt: selected.last?.completedAt,
    740                 hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil
    741             )
    742         } catch {
    743             syncMonitor?.recordError("load recent completed games", error)
    744             return CompletedPage(
    745                 oldestCompletedAt: nil,
    746                 hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil
    747             )
    748         }
    749     }
    750 
    751     /// Continues the metadata scan and hydrates the next available records,
    752     /// crossing arbitrarily large date gaps in one request.
    753     func loadMoreCompleted() async -> CompletedPage {
    754         do {
    755             while uniqueGameCount(in: bufferedCompleted) < Self.completedPageSize,
    756                   let cursor = chronicleCursor {
    757                 let page = try await fetchChronicleMetadataPage(
    758                     continuing: cursor
    759                 )
    760                 chronicleCursor = page.cursor
    761                 bufferedCompleted.append(contentsOf: page.records)
    762                 bufferedCompleted = mergedMetadata(bufferedCompleted)
    763             }
    764 
    765             let selected = Array(bufferedCompleted.prefix(Self.completedPageSize))
    766             let selectedIDs = Set(selected.map(\.originalGameID))
    767             bufferedCompleted.removeAll {
    768                 selectedIDs.contains($0.originalGameID)
    769             }
    770             await hydrateCompleted(selected)
    771             return CompletedPage(
    772                 oldestCompletedAt: selected.last?.completedAt,
    773                 hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil
    774             )
    775         } catch {
    776             syncMonitor?.recordError("load more completed games", error)
    777             return CompletedPage(
    778                 oldestCompletedAt: nil,
    779                 hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil
    780             )
    781         }
    782     }
    783 
    784     /// Brings `ChronicleLedgerEntity` into line with the archive zone, which is
    785     /// authoritative: rows whose Chronicle is gone are dropped, and archives
    786     /// this device has never decoded are read once so the browser's calendar
    787     /// can mark them.
    788     ///
    789     /// The walk is metadata only — record IDs and `completedAt`, 50 to a page —
    790     /// so reconciliation costs one query for a typical library. Only the
    791     /// backfill reads payloads, and it is capped per run: what it doesn't reach
    792     /// stays unknown and is picked up on the next launch, since each run
    793     /// re-derives the outstanding set rather than tracking a cursor.
    794     ///
    795     /// Deliberately walks its own cursor rather than the Game List's paging
    796     /// state: `chronicleCursor` and `bufferedCompleted` belong to Load More,
    797     /// and resetting them here would make the list re-read pages it has shown.
    798     func reconcileChronicleLedger() async {
    799         let identified: [(originalGameID: UUID, recordID: CKRecord.ID)]
    800         do {
    801             identified = try await allChronicleRecordIdentities()
    802         } catch {
    803             syncMonitor?.recordError("scan chronicle ledger", error)
    804             return
    805         }
    806 
    807         let present = Set(identified.map(\.originalGameID))
    808         let ctx = persistence.container.newBackgroundContext()
    809         let (known, removed): (Set<UUID>, Int) = await ctx.perform {
    810             let removed = ChronicleLedgerEntity.retainOnly(present, in: ctx)
    811             if ctx.hasChanges { try? ctx.save() }
    812             return (ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx), removed)
    813         }
    814         if removed > 0 {
    815             eventLog?.note(
    816                 "GameArchiver: chronicle ledger dropped \(removed) stale entr" +
    817                 (removed == 1 ? "y" : "ies")
    818             )
    819         }
    820 
    821         let outstanding = identified.filter { !known.contains($0.originalGameID) }
    822         guard !outstanding.isEmpty else { return }
    823         let batch = Array(outstanding.prefix(Self.chronicleLedgerBackfillLimit))
    824         let written = await backfillChronicleLedger(batch.map(\.recordID))
    825         // Reported whether or not anything was written: a run that reads
    826         // payloads and records none is the signature of a decode that is
    827         // failing silently, and is otherwise invisible from the device.
    828         eventLog?.note(
    829             "GameArchiver: chronicle ledger backfilled \(written) of " +
    830             "\(batch.count) fetched; \(outstanding.count - batch.count) " +
    831             "remaining for a later launch",
    832             level: written < batch.count ? "error" : "info"
    833         )
    834     }
    835 
    836     /// Every Chronicle in the archive zone, metadata only.
    837     private func allChronicleRecordIdentities() async throws
    838         -> [(originalGameID: UUID, recordID: CKRecord.ID)] {
    839         var identities: [(originalGameID: UUID, recordID: CKRecord.ID)] = []
    840         var cursor: CKQueryOperation.Cursor?
    841         repeat {
    842             let page = try await fetchChronicleMetadataPage(continuing: cursor)
    843             cursor = page.cursor
    844             for item in page.records {
    845                 guard case .chronicle(let recordID) = item.source else { continue }
    846                 identities.append((item.originalGameID, recordID))
    847             }
    848         } while cursor != nil
    849         return identities
    850     }
    851 
    852     /// Reads payloads purely to learn which puzzle each archive holds. No
    853     /// `GameEntity` row is created: a Chronicle that isn't in the Game List's
    854     /// window has no business appearing in the library, and materialising it
    855     /// would only be undone by the next trim.
    856     @discardableResult
    857     private func backfillChronicleLedger(_ recordIDs: [CKRecord.ID]) async -> Int {
    858         guard !recordIDs.isEmpty else { return 0 }
    859         let database = container.privateCloudDatabase
    860         let result = try? await database.records(
    861             for: recordIDs,
    862             desiredKeys: Archive.payloadDesiredKeys
    863         )
    864         guard let result else { return 0 }
    865         let payloads = result.values.compactMap { item -> Archive.Payload? in
    866             guard let record = try? item.get() else { return nil }
    867             return Archive.payload(from: record)
    868         }
    869         guard !payloads.isEmpty else { return 0 }
    870 
    871         let ctx = persistence.container.newBackgroundContext()
    872         return await ctx.perform {
    873             for payload in payloads {
    874                 // An unparseable archive still earns its row: the decode has
    875                 // been paid for once, and without a row the backfill would
    876                 // fetch it again on every launch.
    877                 let puzzle = (try? XD.parse(payload.puzzleSource)).map(Puzzle.init(xd:))
    878                 ChronicleLedgerEntity.upsert(
    879                     originalGameID: payload.originalGameID,
    880                     publisher: puzzle?.publisher,
    881                     puzzleDate: puzzle?.date,
    882                     participants: payload.wasShared
    883                         ? payload.participants.map(\.authorID).sorted().joined(separator: ",")
    884                         : nil,
    885                     in: ctx
    886                 )
    887             }
    888             if ctx.hasChanges { try? ctx.save() }
    889             return payloads.count
    890         }
    891     }
    892 
    893     /// One-launch migration backstop for completed live zones that this device
    894     /// has never materialised. It begins only after the bounded Game List load
    895     /// has returned, so old payloads cannot delay or expand the initial list.
    896     /// Each fetched game is immediately compacted; later launches skip it
    897     /// because the retained live row is already local while retirement settles.
    898     func migrateMissingCompletedGames() async {
    899         do {
    900             let metadata = try await fetchCompletedGameMetadata()
    901             let ctx = persistence.container.newBackgroundContext()
    902             var localLiveIDs: Set<UUID> = await ctx.perform {
    903                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    904                 req.predicate = NSPredicate(
    905                     format: "completedAt != nil AND isAccessRevoked == NO " +
    906                         "AND ckRecordName BEGINSWITH %@",
    907                     "game-"
    908                 )
    909                 return Set(((try? ctx.fetch(req)) ?? []).compactMap(\.id))
    910             }
    911 
    912             for item in mergedMetadata(metadata) {
    913                 guard !localLiveIDs.contains(item.originalGameID),
    914                       case .game(let gameID, let zoneID, let scope) = item.source
    915                 else { continue }
    916                 do {
    917                     guard try await syncEngine.fetchCompletedGameDirect(
    918                         gameID: gameID,
    919                         zoneID: zoneID,
    920                         scope: scope
    921                     ) else { continue }
    922                     localLiveIDs.insert(gameID)
    923                     await archiveIfNeeded(gameID: gameID)
    924                 } catch {
    925                     syncMonitor?.recordError("migrate completed game", error)
    926                 }
    927             }
    928             await reconcileUnarchived()
    929         } catch {
    930             syncMonitor?.recordError("scan completed-game migration", error)
    931         }
    932     }
    933 
    934     private func fetchChronicleMetadataPage(
    935         continuing cursor: CKQueryOperation.Cursor? = nil
    936     ) async throws -> (records: [CompletedMetadata], cursor: CKQueryOperation.Cursor?) {
    937         let database = container.privateCloudDatabase
    938         let result: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
    939         if let cursor {
    940             result = try await database.records(
    941                 continuingMatchFrom: cursor,
    942                 desiredKeys: ["completedAt"],
    943                 resultsLimit: 50
    944             )
    945         } else {
    946             let query = CKQuery(recordType: Archive.recordType, predicate: NSPredicate(value: true))
    947             query.sortDescriptors = [NSSortDescriptor(key: "completedAt", ascending: false)]
    948             result = try await database.records(
    949                 matching: query,
    950                 inZoneWith: Archive.zoneID,
    951                 desiredKeys: ["completedAt"],
    952                 resultsLimit: 50
    953             )
    954         }
    955         let records = result.matchResults.compactMap { _, item -> CompletedMetadata? in
    956             guard let record = try? item.get(),
    957                   let originalGameID = Archive.originalGameID(
    958                     fromName: record.recordID.recordName
    959                   ),
    960                   let completedAt = record["completedAt"] as? Date
    961             else { return nil }
    962             return CompletedMetadata(
    963                 originalGameID: originalGameID,
    964                 completedAt: completedAt,
    965                 source: .chronicle(record.recordID)
    966             )
    967         }
    968         return (records, result.queryCursor)
    969     }
    970 
    971     private func fetchCompletedGameMetadata() async throws -> [CompletedMetadata] {
    972         async let privateMetadata = fetchCompletedGameMetadata(
    973             database: container.privateCloudDatabase,
    974             scope: .private
    975         )
    976         async let sharedMetadata = fetchCompletedGameMetadata(
    977             database: container.sharedCloudDatabase,
    978             scope: .shared
    979         )
    980         return try await privateMetadata + sharedMetadata
    981     }
    982 
    983     private func fetchCompletedGameMetadata(
    984         database: CKDatabase,
    985         scope: DatabaseScope
    986     ) async throws -> [CompletedMetadata] {
    987         let zoneIDs = try await database.allRecordZones()
    988             .map(\.zoneID)
    989             .filter { RecordSerializer.gameID(fromGameRecordName: $0.zoneName) != nil }
    990         let recordIDs = zoneIDs.map {
    991             CKRecord.ID(recordName: $0.zoneName, zoneID: $0)
    992         }
    993 
    994         var metadata: [CompletedMetadata] = []
    995         for start in stride(from: 0, to: recordIDs.count, by: 200) {
    996             let end = min(start + 200, recordIDs.count)
    997             let batch = Array(recordIDs[start..<end])
    998             let results = try await database.records(
    999                 for: batch,
   1000                 desiredKeys: ["completedAt"]
   1001             )
   1002             for (recordID, result) in results {
   1003                 guard let record = try? result.get(),
   1004                       let gameID = RecordSerializer.gameID(
   1005                         fromGameRecordName: recordID.recordName
   1006                       ),
   1007                       let completedAt = record["completedAt"] as? Date
   1008                 else { continue }
   1009                 metadata.append(CompletedMetadata(
   1010                     originalGameID: gameID,
   1011                     completedAt: completedAt,
   1012                     source: .game(
   1013                         gameID: gameID,
   1014                         zoneID: recordID.zoneID,
   1015                         scope: scope
   1016                     )
   1017                 ))
   1018             }
   1019         }
   1020         return metadata
   1021     }
   1022 
   1023     /// Collapses a live Game and Chronicle for the same original puzzle into
   1024     /// one candidate. Chronicle is the canonical completed representation;
   1025     /// the live row remains only for acknowledgement and zone retirement.
   1026     private func mergedMetadata(
   1027         _ metadata: [CompletedMetadata]
   1028     ) -> [CompletedMetadata] {
   1029         var byGameID: [UUID: CompletedMetadata] = [:]
   1030         for item in metadata {
   1031             if let existing = byGameID[item.originalGameID] {
   1032                 if !item.isLiveGame && existing.isLiveGame {
   1033                     byGameID[item.originalGameID] = item
   1034                 }
   1035             } else {
   1036                 byGameID[item.originalGameID] = item
   1037             }
   1038         }
   1039         return byGameID.values.sorted { lhs, rhs in
   1040             if lhs.completedAt != rhs.completedAt {
   1041                 return lhs.completedAt > rhs.completedAt
   1042             }
   1043             return lhs.originalGameID.uuidString < rhs.originalGameID.uuidString
   1044         }
   1045     }
   1046 
   1047     private func uniqueGameCount(in metadata: [CompletedMetadata]) -> Int {
   1048         Set(metadata.map(\.originalGameID)).count
   1049     }
   1050 
   1051     private func hydrateCompleted(_ metadata: [CompletedMetadata]) async {
   1052         let chronicles = metadata.compactMap { item -> CKRecord.ID? in
   1053             if case .chronicle(let recordID) = item.source { return recordID }
   1054             return nil
   1055         }
   1056         await materializeChronicles(chronicles)
   1057 
   1058         for item in metadata {
   1059             guard case .game(let gameID, let zoneID, let scope) = item.source
   1060             else { continue }
   1061             do {
   1062                 _ = try await syncEngine.fetchCompletedGameDirect(
   1063                     gameID: gameID,
   1064                     zoneID: zoneID,
   1065                     scope: scope
   1066                 )
   1067             } catch {
   1068                 syncMonitor?.recordError("load completed game", error)
   1069             }
   1070         }
   1071     }
   1072 
   1073     private func materializeChronicles(_ recordIDs: [CKRecord.ID]) async {
   1074         guard !recordIDs.isEmpty else { return }
   1075         let database = container.privateCloudDatabase
   1076         let result = try? await database.records(
   1077             for: recordIDs,
   1078             desiredKeys: Archive.payloadDesiredKeys
   1079         )
   1080         guard let result else { return }
   1081         let records = result.compactMap { _, item in try? item.get() }
   1082         let ctx = persistence.container.newBackgroundContext()
   1083         await ctx.perform {
   1084             for record in records {
   1085                 _ = self.syncEngine.applyPreferredArchiveRecord(record, in: ctx)
   1086             }
   1087             if ctx.hasChanges { try? ctx.save() }
   1088         }
   1089     }
   1090 
   1091     /// Evicts only rows materialized from Chronicle. Live completed games stay
   1092     /// local until their archive/acknowledgement/retirement work has converged.
   1093     private func trimMaterializedChronicles(before cutoff: Date) async {
   1094         let ctx = persistence.container.newBackgroundContext()
   1095         await ctx.perform {
   1096             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1097             req.predicate = NSPredicate(
   1098                 format: "completedAt < %@ AND ckRecordName BEGINSWITH %@",
   1099                 cutoff as NSDate,
   1100                 "chronicle-"
   1101             )
   1102             for game in (try? ctx.fetch(req)) ?? [] {
   1103                 if let name = game.ckRecordName,
   1104                    let originalID = Archive.originalGameID(fromName: name) {
   1105                     let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1106                     liveReq.predicate = NSPredicate(
   1107                         format: "id == %@",
   1108                         originalID as CVarArg
   1109                     )
   1110                     liveReq.fetchLimit = 1
   1111                     (try? ctx.fetch(liveReq).first)?
   1112                         .isSupersededByChronicle = false
   1113                 }
   1114                 ctx.delete(game)
   1115             }
   1116             if ctx.hasChanges { try? ctx.save() }
   1117         }
   1118     }
   1119 
   1120     private func fetchArchive(originalGameID: UUID) async -> StoredArchive? {
   1121         let name = Archive.recordName(forOriginalGameID: originalGameID)
   1122         let commonID = CKRecord.ID(recordName: name, zoneID: Archive.zoneID)
   1123         if let record = try? await container.privateCloudDatabase.record(for: commonID),
   1124            let payload = Archive.payload(from: record) {
   1125             return StoredArchive(payload: payload, isLegacy: false)
   1126         }
   1127         let legacyID = CKRecord.ID(
   1128             recordName: Archive.legacyRecordName(forOriginalGameID: originalGameID),
   1129             zoneID: Archive.legacyZoneID(forOriginalGameID: originalGameID)
   1130         )
   1131         if let record = try? await container.privateCloudDatabase.record(for: legacyID),
   1132            let payload = Archive.payload(from: record) {
   1133             return StoredArchive(payload: payload, isLegacy: true)
   1134         }
   1135         return nil
   1136     }
   1137 
   1138     @discardableResult
   1139     private func write(
   1140         _ snapshot: Archive.Snapshot,
   1141         replayState: Archive.ReplayState
   1142     ) async -> Bool {
   1143         do {
   1144             try await ensureArchiveZone()
   1145             let package = try Archive.recordPackage(
   1146                 from: snapshot,
   1147                 replayState: replayState
   1148             )
   1149             defer { removeTemporaryArchiveFiles(package.temporaryAssetFileURLs) }
   1150             try await save(package.record)
   1151             return true
   1152         } catch {
   1153             syncMonitor?.recordError("archive game", error)
   1154             eventLog?.note(
   1155                 "GameArchiver: write deferred for \(snapshot.originalGameID.uuidString); " +
   1156                 "will retry on cold launch — \(error)",
   1157                 level: "error"
   1158             )
   1159             return false
   1160         }
   1161     }
   1162 
   1163     private func ensureArchiveZone() async throws {
   1164         guard !ensuredArchiveZone else { return }
   1165         do {
   1166             try await createArchiveZone(Archive.zoneID)
   1167         } catch {
   1168             guard Self.isZoneAlreadyExists(error) else { throw error }
   1169         }
   1170         ensuredArchiveZone = true
   1171     }
   1172 
   1173     private func createArchiveZone(_ zoneID: CKRecordZone.ID) async throws {
   1174         try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
   1175             let operation = CKModifyRecordZonesOperation(
   1176                 recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
   1177                 recordZoneIDsToDelete: nil
   1178             )
   1179             operation.qualityOfService = .utility
   1180             operation.modifyRecordZonesResultBlock = { continuation.resume(with: $0) }
   1181             container.privateCloudDatabase.add(operation)
   1182         }
   1183     }
   1184 
   1185     private func save(_ record: CKRecord) async throws {
   1186         try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
   1187             let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
   1188             operation.savePolicy = .allKeys
   1189             operation.qualityOfService = .utility
   1190             operation.modifyRecordsResultBlock = { continuation.resume(with: $0) }
   1191             container.privateCloudDatabase.add(operation)
   1192         }
   1193     }
   1194 
   1195     private nonisolated static func isZoneAlreadyExists(_ error: Error) -> Bool {
   1196         guard let ckError = error as? CKError else { return false }
   1197         if ckError.code == .serverRejectedRequest {
   1198             return ckError.localizedDescription.lowercased().contains("already exist")
   1199         }
   1200         if ckError.code == .partialFailure {
   1201             return ckError.partialErrorsByItemID?.values.contains {
   1202                 isZoneAlreadyExists($0)
   1203             } ?? false
   1204         }
   1205         return false
   1206     }
   1207 
   1208     private func removeTemporaryArchiveFiles(_ urls: [URL]) {
   1209         for url in urls {
   1210             do {
   1211                 try FileManager.default.removeItem(at: url)
   1212             } catch {
   1213                 eventLog?.note(
   1214                     "GameArchiver: failed to remove temporary archive asset " +
   1215                     "\(url.lastPathComponent) — \(error)",
   1216                     level: "error"
   1217                 )
   1218             }
   1219         }
   1220     }
   1221 
   1222     // MARK: - Account-wide migration grace
   1223 
   1224     private static let graceKey = "archiveGraceStart.v1.1.0"
   1225 
   1226     /// Uses ubiquitous key-value storage as an account-wide shared default,
   1227     /// with an author-keyed local fallback for immediate reads and account
   1228     /// switches. Devices continually publish the earliest value they have seen,
   1229     /// so a delayed KVS update can postpone retirement but can never make the
   1230     /// grace period shorter than the first v1.1.0 launch observed by a device.
   1231     private func accountGraceStart() async -> Date? {
   1232         guard let authorID = localIdentity()?.authorID, !authorID.isEmpty else { return nil }
   1233         let localKey = "\(Self.graceKey).\(authorID)"
   1234         ubiquitousStore?.synchronize()
   1235         let localValue = localDefaults.object(forKey: localKey) as? Double
   1236         let cloudValue = ubiquitousStore?.object(forKey: Self.graceKey) as? Double
   1237         let earliest = [localValue, cloudValue].compactMap { $0 }.min()
   1238             ?? Date().timeIntervalSince1970
   1239         localDefaults.set(earliest, forKey: localKey)
   1240         if cloudValue == nil || earliest < cloudValue! {
   1241             ubiquitousStore?.set(earliest, forKey: Self.graceKey)
   1242             ubiquitousStore?.synchronize()
   1243         }
   1244         return Date(timeIntervalSince1970: earliest)
   1245     }
   1246 }