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 }