crossmate

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

MovesUpdater.swift (11789B)


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