ChronicleLedger.swift (5253B)
1 import CoreData 2 import Foundation 3 4 /// A local-only note of which puzzle each Chronicle holds. 5 /// 6 /// Completed games are compacted into Chronicle records and their local rows 7 /// are evicted once they fall outside the Game List's window, so the store on 8 /// its own can't answer "have I played this puzzle before?" for anything older 9 /// than a few days. The Chronicle carries only `completedAt` and an opaque 10 /// payload — publication date lives inside the blob — so the question can't be 11 /// asked of CloudKit cheaply either. 12 /// 13 /// This ledger records the answer the one time a payload is decoded, and 14 /// survives the eviction that discards the row. It is derived state, not a 15 /// second source of truth: the archive zone's record list stays authoritative, 16 /// and `retainOnly(_:in:)` drops anything it no longer contains. 17 extension ChronicleLedgerEntity { 18 /// Records what a decoded payload said about its puzzle. Existing rows are 19 /// updated in place: `originalGameID` is the identity, and a Chronicle 20 /// rewritten with a longer journal describes the same puzzle as before. 21 static func upsert( 22 originalGameID: UUID, 23 publisher: String?, 24 puzzleDate: Date?, 25 participants: String?, 26 in ctx: NSManagedObjectContext 27 ) { 28 // Written even when the puzzle carries no publication date. Such a row 29 // marks no day — readers require a date — but it records that the 30 // payload has been decoded, so the backfill doesn't re-fetch the same 31 // archive on every launch to reach the same conclusion. 32 let entity = existing(originalGameID: originalGameID, in: ctx) 33 ?? ChronicleLedgerEntity(context: ctx) 34 entity.originalGameID = originalGameID 35 entity.publisher = publisher 36 entity.puzzleDate = puzzleDate 37 entity.participants = participants 38 } 39 40 static func remove(originalGameID: UUID, in ctx: NSManagedObjectContext) { 41 guard let entity = existing(originalGameID: originalGameID, in: ctx) else { return } 42 ctx.delete(entity) 43 } 44 45 /// The archives this device has already decoded, so a backfill pass can ask 46 /// CloudKit only for the ones it has never seen. 47 static func knownOriginalGameIDs(in ctx: NSManagedObjectContext) -> Set<UUID> { 48 let request = NSFetchRequest<NSDictionary>(entityName: "ChronicleLedgerEntity") 49 request.resultType = .dictionaryResultType 50 request.propertiesToFetch = ["originalGameID"] 51 let rows = (try? ctx.fetch(request)) ?? [] 52 return Set(rows.compactMap { $0["originalGameID"] as? UUID }) 53 } 54 55 /// Drops rows whose Chronicle is no longer in the archive zone — the games 56 /// deleted on another device. Deletions made here prune inline instead. 57 /// Returns the number removed. 58 @discardableResult 59 static func retainOnly( 60 _ originalGameIDs: Set<UUID>, 61 in ctx: NSManagedObjectContext 62 ) -> Int { 63 let request = NSFetchRequest<ChronicleLedgerEntity>(entityName: "ChronicleLedgerEntity") 64 var removed = 0 65 for entity in (try? ctx.fetch(request)) ?? [] { 66 guard let id = entity.originalGameID, !originalGameIDs.contains(id) else { continue } 67 ctx.delete(entity) 68 removed += 1 69 } 70 return removed 71 } 72 73 /// Publication dates recorded for `publisher`, excluding any game featuring 74 /// a currently blocked collaborator. The block table is consulted at read 75 /// time rather than mirrored onto these rows, so blocking and unblocking 76 /// need no reconciliation pass of their own. 77 static func puzzleDates( 78 publisher: String, 79 blockedAuthorIDs: Set<String>, 80 in ctx: NSManagedObjectContext 81 ) -> [Date] { 82 let request = NSFetchRequest<NSDictionary>(entityName: "ChronicleLedgerEntity") 83 request.resultType = .dictionaryResultType 84 request.propertiesToFetch = ["puzzleDate", "participants"] 85 request.predicate = NSPredicate( 86 format: "publisher == %@ AND puzzleDate != nil", 87 publisher 88 ) 89 let rows = (try? ctx.fetch(request)) ?? [] 90 return rows.compactMap { row in 91 guard let date = row["puzzleDate"] as? Date else { return nil } 92 let participants = (row["participants"] as? String) 93 .map(Self.participantAuthorIDs) ?? [] 94 guard blockedAuthorIDs.isDisjoint(with: participants) else { return nil } 95 return date 96 } 97 } 98 99 /// Matches `GameEntity.archiveParticipants`, which stores the sorted author 100 /// IDs of a shared game as one comma-separated string. 101 static func participantAuthorIDs(_ stored: String) -> Set<String> { 102 Set(stored.split(separator: ",").map(String.init).filter { !$0.isEmpty }) 103 } 104 105 private static func existing( 106 originalGameID: UUID, 107 in ctx: NSManagedObjectContext 108 ) -> ChronicleLedgerEntity? { 109 let request = NSFetchRequest<ChronicleLedgerEntity>(entityName: "ChronicleLedgerEntity") 110 request.predicate = NSPredicate(format: "originalGameID == %@", originalGameID as CVarArg) 111 request.fetchLimit = 1 112 return try? ctx.fetch(request).first 113 } 114 }