crossmate

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

MovesUpdater.swift (12124B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// In-memory staging area for cell edits. Coalesces rapid same-cell edits down
      5 /// to one per-cell entry, on flush merges them into the local device's
      6 /// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity`
      7 /// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine
      8 /// can enqueue Moves records for upload.
      9 ///
     10 /// Flush triggers:
     11 /// - trailing-edge debounce (the user has stopped typing);
     12 /// - cell change (focus moves to a different cell);
     13 /// - explicit `flush()` (view exit, app background, game completion, tests).
     14 actor MovesUpdater {
     15     private struct Key: Hashable {
     16         let gameID: UUID
     17         let row: Int
     18         let col: Int
     19     }
     20 
     21     private struct Pending {
     22         var letter: String
     23         var markKind: Int16
     24         var checkedWrong: Bool
     25         var authorID: String?
     26         var enqueuedAt: Date
     27     }
     28 
     29     private let debounceInterval: Duration
     30     private let sessionPingStaleInterval: TimeInterval
     31     private let persistence: PersistenceController
     32     private let writerAuthorIDProvider: @Sendable () async -> String?
     33     private let sink: @Sendable (Set<UUID>) async -> Void
     34     private let sessionPingSink: (@Sendable (UUID, String) async -> Void)?
     35     /// Sleep primitive used by the debounce timer. Injected so tests can
     36     /// drive flushes deterministically instead of racing against wall-clock
     37     /// `Task.sleep` from the actor's own task queue.
     38     private let sleep: @Sendable (Duration) async throws -> Void
     39 
     40     private var buffer: [Key: Pending] = [:]
     41     /// Cell most recently enqueued. A subsequent enqueue targeting a different
     42     /// cell flushes first so `updatedAt` ordering in the merged grid matches
     43     /// editing order for cells whose wall clocks are within the same tick.
     44     private var lastCell: Key?
     45     private var debounceTask: Task<Void, Never>?
     46     /// Per-game timestamp of the last session ping fired. The first `enqueue`
     47     /// for a game with no entry — or one stale beyond
     48     /// `sessionPingStaleInterval` — counts as a new session and fires a ping.
     49     private var lastSessionPingAt: [UUID: Date] = [:]
     50 
     51     init(
     52         debounceInterval: Duration = .milliseconds(500),
     53         sessionPingStaleInterval: TimeInterval = 20 * 60,
     54         persistence: PersistenceController,
     55         writerAuthorIDProvider: @escaping @Sendable () async -> String?,
     56         sink: @escaping @Sendable (Set<UUID>) async -> Void,
     57         sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil,
     58         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
     59     ) {
     60         self.debounceInterval = debounceInterval
     61         self.sessionPingStaleInterval = sessionPingStaleInterval
     62         self.persistence = persistence
     63         self.writerAuthorIDProvider = writerAuthorIDProvider
     64         self.sink = sink
     65         self.sessionPingSink = sessionPingSink
     66         self.sleep = sleep
     67     }
     68 
     69     /// Registers a cell edit. `authorID` is the cell-effective author that
     70     /// gets persisted into the merged grid — it may differ from the acting
     71     /// (writer) user when a same-letter rewrite or a reveal-of-correct
     72     /// preserves the original author. The acting user is still passed
     73     /// separately so session pings fire on the typist.
     74     func enqueue(
     75         gameID: UUID,
     76         row: Int,
     77         col: Int,
     78         letter: String,
     79         markKind: Int16,
     80         checkedWrong: Bool,
     81         authorID: String?,
     82         actingAuthorID: String? = nil
     83     ) async {
     84         let key = Key(gameID: gameID, row: row, col: col)
     85 
     86         if let lastCell, lastCell != key {
     87             await performFlush()
     88         }
     89 
     90         buffer[key] = Pending(
     91             letter: letter,
     92             markKind: markKind,
     93             checkedWrong: checkedWrong,
     94             authorID: authorID,
     95             enqueuedAt: Date()
     96         )
     97         lastCell = key
     98         scheduleDebounce()
     99 
    100         let pingAuthorID = actingAuthorID ?? authorID
    101         if let pingAuthorID, !pingAuthorID.isEmpty {
    102             await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID)
    103         }
    104     }
    105 
    106     /// Resets session-ping tracking for `gameID`. Called from the puzzle
    107     /// view's teardown so re-entry counts as a fresh session.
    108     func noteSessionEnded(gameID: UUID) {
    109         lastSessionPingAt.removeValue(forKey: gameID)
    110     }
    111 
    112     /// Flushes any pending edits immediately and cancels the debounce. Safe
    113     /// to call when the buffer is empty.
    114     func flush() async {
    115         debounceTask?.cancel()
    116         debounceTask = nil
    117         await performFlush()
    118     }
    119 
    120     private func maybeFireSessionPing(gameID: UUID, authorID: String) async {
    121         let now = Date()
    122         if let last = lastSessionPingAt[gameID],
    123            now.timeIntervalSince(last) < sessionPingStaleInterval {
    124             return
    125         }
    126         lastSessionPingAt[gameID] = now
    127         if let sessionPingSink {
    128             await sessionPingSink(gameID, authorID)
    129         }
    130     }
    131 
    132     private func scheduleDebounce() {
    133         debounceTask?.cancel()
    134         let interval = debounceInterval
    135         let sleep = self.sleep
    136         debounceTask = Task { [weak self] in
    137             try? await sleep(interval)
    138             if Task.isCancelled { return }
    139             await self?.debouncedFlush()
    140         }
    141     }
    142 
    143     private func debouncedFlush() async {
    144         debounceTask = nil
    145         await performFlush()
    146     }
    147 
    148     private func performFlush() async {
    149         guard !buffer.isEmpty else { return }
    150         // The parent record name embeds the writer's authorID; without it we
    151         // can't address the row yet. Keep the buffer intact and retry rather
    152         // than losing live in-memory edits that have not reached Core Data.
    153         guard let writerAuthorID = await writerAuthorIDProvider(),
    154               !writerAuthorID.isEmpty
    155         else {
    156             scheduleDebounce()
    157             return
    158         }
    159 
    160         let snapshot = buffer
    161         buffer.removeAll(keepingCapacity: true)
    162         lastCell = nil
    163 
    164         guard let affected = persistAndMerge(
    165             snapshot: snapshot,
    166             writerAuthorID: writerAuthorID
    167         ) else {
    168             buffer.merge(snapshot) { current, _ in current }
    169             scheduleDebounce()
    170             return
    171         }
    172         guard !affected.isEmpty else { return }
    173         await sink(affected)
    174     }
    175 
    176     /// Merges buffered edits into the local device's `MovesEntity` row per
    177     /// game. Idempotent: existing per-cell entries are kept if they have a
    178     /// later `updatedAt` than the new edit (defensive against wall-clock
    179     /// regressions).
    180     private func persistAndMerge(
    181         snapshot: [Key: Pending],
    182         writerAuthorID: String
    183     ) -> Set<UUID>? {
    184         let context = persistence.container.newBackgroundContext()
    185         return context.performAndWait {
    186             var byGame: [UUID: [(Key, Pending)]] = [:]
    187             for (key, pending) in snapshot {
    188                 byGame[key.gameID, default: []].append((key, pending))
    189             }
    190 
    191             var affected = Set<UUID>()
    192             for (gameID, edits) in byGame {
    193                 guard let game = Self.fetchGame(gameID: gameID, in: context) else { continue }
    194 
    195                 let movesEntity = Self.ensureMovesEntity(
    196                     for: gameID,
    197                     writerAuthorID: writerAuthorID,
    198                     game: game,
    199                     in: context
    200                 )
    201 
    202                 var existing: [GridPosition: TimestampedCell] = [:]
    203                 if let data = movesEntity.cells, !data.isEmpty {
    204                     existing = (try? MovesCodec.decode(data)) ?? [:]
    205                 }
    206 
    207                 var maxUpdatedAt = movesEntity.updatedAt ?? .distantPast
    208                 var cellCacheMap = Self.cellCacheMap(for: game)
    209 
    210                 for (key, pending) in edits {
    211                     let position = GridPosition(row: key.row, col: key.col)
    212                     let newCell = TimestampedCell(
    213                         letter: pending.letter,
    214                         markKind: pending.markKind,
    215                         checkedWrong: pending.checkedWrong,
    216                         updatedAt: pending.enqueuedAt,
    217                         authorID: pending.authorID
    218                     )
    219                     if let current = existing[position],
    220                        current.updatedAt > newCell.updatedAt {
    221                         continue
    222                     }
    223                     existing[position] = newCell
    224                     if newCell.updatedAt > maxUpdatedAt {
    225                         maxUpdatedAt = newCell.updatedAt
    226                     }
    227 
    228                     Self.updateCellCache(
    229                         for: game,
    230                         key: key,
    231                         pending: pending,
    232                         cells: &cellCacheMap,
    233                         in: context
    234                     )
    235                 }
    236 
    237                 movesEntity.cells = (try? MovesCodec.encode(existing)) ?? Data()
    238                 movesEntity.updatedAt = maxUpdatedAt
    239                 if game.updatedAt.map({ $0 < maxUpdatedAt }) ?? true {
    240                     game.updatedAt = maxUpdatedAt
    241                 }
    242                 affected.insert(gameID)
    243             }
    244 
    245             if context.hasChanges {
    246                 do {
    247                     try context.save()
    248                 } catch {
    249                     print("MovesUpdater: failed to save context: \(error)")
    250                     return nil
    251                 }
    252             }
    253             return affected
    254         }
    255     }
    256 
    257     private nonisolated static func fetchGame(
    258         gameID: UUID,
    259         in ctx: NSManagedObjectContext
    260     ) -> GameEntity? {
    261         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    262         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    263         req.fetchLimit = 1
    264         return try? ctx.fetch(req).first
    265     }
    266 
    267     private nonisolated static func ensureMovesEntity(
    268         for gameID: UUID,
    269         writerAuthorID: String,
    270         game: GameEntity,
    271         in ctx: NSManagedObjectContext
    272     ) -> MovesEntity {
    273         let deviceID = RecordSerializer.localDeviceID
    274         let recordName = RecordSerializer.recordName(
    275             forMovesInGame: gameID,
    276             authorID: writerAuthorID,
    277             deviceID: deviceID
    278         )
    279         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    280         req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    281         req.fetchLimit = 1
    282         if let existing = try? ctx.fetch(req).first {
    283             return existing
    284         }
    285         let entity = MovesEntity(context: ctx)
    286         entity.game = game
    287         entity.ckRecordName = recordName
    288         entity.authorID = writerAuthorID
    289         entity.deviceID = deviceID
    290         entity.cells = Data()
    291         entity.updatedAt = Date()
    292         return entity
    293     }
    294 
    295     private nonisolated static func cellCacheMap(
    296         for game: GameEntity
    297     ) -> [GridPosition: CellEntity] {
    298         let cellEntities = (game.cells as? Set<CellEntity>) ?? []
    299         var cells: [GridPosition: CellEntity] = [:]
    300         cells.reserveCapacity(cellEntities.count)
    301         for cell in cellEntities {
    302             cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
    303         }
    304         return cells
    305     }
    306 
    307     private nonisolated static func updateCellCache(
    308         for game: GameEntity,
    309         key: Key,
    310         pending: Pending,
    311         cells: inout [GridPosition: CellEntity],
    312         in context: NSManagedObjectContext
    313     ) {
    314         let position = GridPosition(row: key.row, col: key.col)
    315         let cell: CellEntity
    316         if let existing = cells[position] {
    317             cell = existing
    318         } else {
    319             cell = CellEntity(context: context)
    320             cell.game = game
    321             cell.row = Int16(key.row)
    322             cell.col = Int16(key.col)
    323             cells[position] = cell
    324         }
    325         cell.letter = pending.letter
    326         cell.markKind = pending.markKind
    327         cell.checkedWrong = pending.checkedWrong
    328         cell.letterAuthorID = pending.authorID
    329     }
    330 }