crossmate

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

MovesUpdater.swift (11542B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// In-memory staging area for cell edits. Coalesces rapid edits — same-cell
      5 /// rewrites collapse to the latest value, cross-cell edits accumulate as
      6 /// distinct entries — and on flush merges them into the local device's
      7 /// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity`
      8 /// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine
      9 /// can enqueue Moves records for upload.
     10 ///
     11 /// Flush triggers:
     12 /// - trailing-edge debounce (the user has stopped typing);
     13 /// - explicit `flush()` (view exit, app background, game completion, tests).
     14 ///
     15 /// The UI does not depend on a flush for typed letters to appear — those go
     16 /// straight into the in-memory `Puzzle` via `GameMutator`. This staging area
     17 /// is purely for durable persistence + CloudKit handoff, so coalescing a full
     18 /// across-or-down word into one flush is a pure win: fewer Core Data writes
     19 /// and one CloudKit push per typing burst instead of one per cell.
     20 actor MovesUpdater {
     21     private struct Key: Hashable {
     22         let gameID: UUID
     23         let row: Int
     24         let col: Int
     25     }
     26 
     27     private struct Pending {
     28         var letter: String
     29         var mark: CellMark
     30         var authorID: String?
     31         var enqueuedAt: Date
     32     }
     33 
     34     private let debounceInterval: Duration
     35     private let persistence: PersistenceController
     36     private let writerAuthorIDProvider: @Sendable () async -> String?
     37     /// `drain` tells the sink whether to force a CKSyncEngine send: `true` for
     38     /// an explicit `flush()` (leave/background — the solver's final letters
     39     /// must reach CloudKit promptly even with no peer on the socket), `false`
     40     /// for the trailing-edge debounce (live typing, where the engagement
     41     /// socket already carries the letters and the framework's own scheduler
     42     /// can coalesce the durable writes).
     43     private let sink: @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void
     44     /// Sleep primitive used by the debounce timer. Injected so tests can
     45     /// drive flushes deterministically instead of racing against wall-clock
     46     /// `Task.sleep` from the actor's own task queue.
     47     private let sleep: @Sendable (Duration) async throws -> Void
     48 
     49     private var buffer: [Key: Pending] = [:]
     50     private var debounceTask: Task<Void, Never>?
     51 
     52     init(
     53         debounceInterval: Duration = .milliseconds(500),
     54         persistence: PersistenceController,
     55         writerAuthorIDProvider: @escaping @Sendable () async -> String?,
     56         sink: @escaping @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void,
     57         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
     58     ) {
     59         self.debounceInterval = debounceInterval
     60         self.persistence = persistence
     61         self.writerAuthorIDProvider = writerAuthorIDProvider
     62         self.sink = sink
     63         self.sleep = sleep
     64     }
     65 
     66     /// Registers a cell edit. `authorID` is the cell-effective author that
     67     /// gets persisted into the merged grid — it may differ from the writer
     68     /// when a same-letter rewrite or a reveal-of-correct preserves the
     69     /// original author.
     70     func enqueue(
     71         gameID: UUID,
     72         row: Int,
     73         col: Int,
     74         letter: String,
     75         mark: CellMark,
     76         authorID: String?,
     77         actingAuthorID: String? = nil,
     78         enqueuedAt: Date = Date()
     79     ) async {
     80         let key = Key(gameID: gameID, row: row, col: col)
     81         buffer[key] = Pending(
     82             letter: letter,
     83             mark: mark,
     84             authorID: authorID,
     85             enqueuedAt: enqueuedAt
     86         )
     87         scheduleDebounce()
     88     }
     89 
     90     /// Flushes any pending edits immediately and cancels the debounce. Safe
     91     /// to call when the buffer is empty.
     92     func flush() async {
     93         debounceTask?.cancel()
     94         debounceTask = nil
     95         await performFlush(drain: true)
     96     }
     97 
     98     private func scheduleDebounce() {
     99         debounceTask?.cancel()
    100         let interval = debounceInterval
    101         let sleep = self.sleep
    102         debounceTask = Task { [weak self] in
    103             try? await sleep(interval)
    104             if Task.isCancelled { return }
    105             await self?.debouncedFlush()
    106         }
    107     }
    108 
    109     private func debouncedFlush() async {
    110         debounceTask = nil
    111         await performFlush(drain: false)
    112     }
    113 
    114     private func performFlush(drain: Bool) async {
    115         guard !buffer.isEmpty else { return }
    116         // The parent record name embeds the writer's authorID; without it we
    117         // can't address the row yet. Keep the buffer intact and retry rather
    118         // than losing live in-memory edits that have not reached Core Data.
    119         guard let writerAuthorID = await writerAuthorIDProvider(),
    120               !writerAuthorID.isEmpty
    121         else {
    122             scheduleDebounce()
    123             return
    124         }
    125 
    126         let snapshot = buffer
    127         buffer.removeAll(keepingCapacity: true)
    128 
    129         guard let affected = persistAndMerge(
    130             snapshot: snapshot,
    131             writerAuthorID: writerAuthorID
    132         ) else {
    133             buffer.merge(snapshot) { current, _ in current }
    134             scheduleDebounce()
    135             return
    136         }
    137         guard !affected.isEmpty else { return }
    138         await sink(affected, drain)
    139     }
    140 
    141     /// Merges buffered edits into the local device's `MovesEntity` row per
    142     /// game. Idempotent: existing per-cell entries are kept if they have a
    143     /// later `updatedAt` than the new edit (defensive against wall-clock
    144     /// regressions).
    145     private func persistAndMerge(
    146         snapshot: [Key: Pending],
    147         writerAuthorID: String
    148     ) -> Set<UUID>? {
    149         let context = persistence.container.newBackgroundContext()
    150         // This flush links MovesEntity/CellEntity to their GameEntity and bumps
    151         // game.updatedAt, while the sync engine writes the same rows from its own
    152         // context. Without an explicit policy the default NSErrorMergePolicy would
    153         // fail the save on an optimistic-lock conflict (Core Data 133020) and lose
    154         // the whole flush — the grid-side analogue of the journal-drop bug. Match
    155         // the rest of the sync layer with mergeByPropertyObjectTrump, which also
    156         // keeps the grid fail-safe to the local typist on a same-cell race.
    157         context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    158         return context.performAndWait {
    159             var byGame: [UUID: [(Key, Pending)]] = [:]
    160             for (key, pending) in snapshot {
    161                 byGame[key.gameID, default: []].append((key, pending))
    162             }
    163 
    164             var affected = Set<UUID>()
    165             for (gameID, edits) in byGame {
    166                 guard let game = Self.fetchGame(gameID: gameID, in: context) else { continue }
    167 
    168                 let movesEntity = Self.ensureMovesEntity(
    169                     for: gameID,
    170                     writerAuthorID: writerAuthorID,
    171                     game: game,
    172                     in: context
    173                 )
    174 
    175                 var existing: [GridPosition: TimestampedCell] = [:]
    176                 if let data = movesEntity.cells, !data.isEmpty {
    177                     existing = (try? MovesCodec.decode(data)) ?? [:]
    178                 }
    179 
    180                 var maxUpdatedAt = movesEntity.updatedAt ?? .distantPast
    181                 var cellCacheMap = Self.cellCacheMap(for: game)
    182 
    183                 for (key, pending) in edits {
    184                     let position = GridPosition(row: key.row, col: key.col)
    185                     let newCell = TimestampedCell(
    186                         letter: pending.letter,
    187                         mark: pending.mark,
    188                         updatedAt: pending.enqueuedAt,
    189                         authorID: pending.authorID
    190                     )
    191                     if let current = existing[position],
    192                        current.updatedAt > newCell.updatedAt {
    193                         continue
    194                     }
    195                     existing[position] = newCell
    196                     if newCell.updatedAt > maxUpdatedAt {
    197                         maxUpdatedAt = newCell.updatedAt
    198                     }
    199 
    200                     Self.updateCellCache(
    201                         for: game,
    202                         key: key,
    203                         pending: pending,
    204                         cells: &cellCacheMap,
    205                         in: context
    206                     )
    207                 }
    208 
    209                 movesEntity.cells = (try? MovesCodec.encode(existing)) ?? Data()
    210                 movesEntity.updatedAt = maxUpdatedAt
    211                 if game.updatedAt.map({ $0 < maxUpdatedAt }) ?? true {
    212                     game.updatedAt = maxUpdatedAt
    213                 }
    214                 affected.insert(gameID)
    215             }
    216 
    217             if context.hasChanges {
    218                 do {
    219                     try context.save()
    220                 } catch {
    221                     print("MovesUpdater: failed to save context: \(error)")
    222                     return nil
    223                 }
    224             }
    225             return affected
    226         }
    227     }
    228 
    229     private nonisolated static func fetchGame(
    230         gameID: UUID,
    231         in ctx: NSManagedObjectContext
    232     ) -> GameEntity? {
    233         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    234         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    235         req.fetchLimit = 1
    236         return try? ctx.fetch(req).first
    237     }
    238 
    239     private nonisolated static func ensureMovesEntity(
    240         for gameID: UUID,
    241         writerAuthorID: String,
    242         game: GameEntity,
    243         in ctx: NSManagedObjectContext
    244     ) -> MovesEntity {
    245         let deviceID = RecordSerializer.localDeviceID
    246         let recordName = RecordSerializer.recordName(
    247             forMovesInGame: gameID,
    248             authorID: writerAuthorID,
    249             deviceID: deviceID
    250         )
    251         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    252         req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    253         req.fetchLimit = 1
    254         if let existing = try? ctx.fetch(req).first {
    255             return existing
    256         }
    257         let entity = MovesEntity(context: ctx)
    258         entity.game = game
    259         entity.ckRecordName = recordName
    260         entity.authorID = writerAuthorID
    261         entity.deviceID = deviceID
    262         entity.cells = Data()
    263         entity.updatedAt = Date()
    264         return entity
    265     }
    266 
    267     private nonisolated static func cellCacheMap(
    268         for game: GameEntity
    269     ) -> [GridPosition: CellEntity] {
    270         let cellEntities = (game.cells as? Set<CellEntity>) ?? []
    271         var cells: [GridPosition: CellEntity] = [:]
    272         cells.reserveCapacity(cellEntities.count)
    273         for cell in cellEntities {
    274             cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
    275         }
    276         return cells
    277     }
    278 
    279     private nonisolated static func updateCellCache(
    280         for game: GameEntity,
    281         key: Key,
    282         pending: Pending,
    283         cells: inout [GridPosition: CellEntity],
    284         in context: NSManagedObjectContext
    285     ) {
    286         let position = GridPosition(row: key.row, col: key.col)
    287         let cell: CellEntity
    288         if let existing = cells[position] {
    289             cell = existing
    290         } else {
    291             cell = CellEntity(context: context)
    292             cell.game = game
    293             cell.row = Int16(key.row)
    294             cell.col = Int16(key.col)
    295             cells[position] = cell
    296         }
    297         cell.letter = pending.letter
    298         cell.markCode = pending.mark.code
    299         cell.letterAuthorID = pending.authorID
    300     }
    301 }