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 }