crossmate

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

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 }