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 }