PersistenceController.swift (18144B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 /// Wraps the app's `NSPersistentContainer`. Plain Core Data with no 6 /// CloudKit mirroring — sync (single-user iPhone↔iPad and CKShare 7 /// collaboration alike) is the job of a separate sync engine that will 8 /// drive CloudKit directly on top of this same store. See PLAN.md for the 9 /// layered design. 10 @MainActor 11 final class PersistenceController { 12 let container: NSPersistentContainer 13 14 var viewContext: NSManagedObjectContext { container.viewContext } 15 16 let eventLog: EventLog? 17 18 init(inMemory: Bool = false, storeURL: URL? = nil, eventLog: EventLog? = nil) { 19 self.eventLog = eventLog 20 container = NSPersistentContainer( 21 name: "CrossmateModel", 22 managedObjectModel: Self.sharedModel 23 ) 24 25 if inMemory { 26 // NSInMemoryStoreType keeps each store fully isolated in process 27 // memory with no file involvement, which prevents concurrent test 28 // runs from colliding through shared SQLite WAL files at /dev/null. 29 let description = NSPersistentStoreDescription() 30 description.type = NSInMemoryStoreType 31 container.persistentStoreDescriptions = [description] 32 } else { 33 // The app always uses the container's default location; tests 34 // point the store at a throwaway URL to exercise the on-disk 35 // load/recovery path without touching real data. 36 if let storeURL { 37 container.persistentStoreDescriptions = [ 38 NSPersistentStoreDescription(url: storeURL) 39 ] 40 } 41 // Enable lightweight migration so additive schema changes — and 42 // attribute renames carrying a `renamingIdentifier` in the model 43 // — apply on launch without a hand-written mapping. A non-additive 44 // change made in place (no prior model version kept as a migration 45 // source) can't be inferred; `recreateStore(after:)` handles that 46 // by discarding and rebuilding the store. 47 for description in container.persistentStoreDescriptions { 48 description.shouldMigrateStoreAutomatically = true 49 description.shouldInferMappingModelAutomatically = true 50 } 51 } 52 53 container.loadPersistentStores { [self] _, error in 54 guard let error else { return } 55 if inMemory { 56 fatalError("Failed to load in-memory Core Data store: \(error)") 57 } 58 recreateStore(after: error) 59 } 60 61 container.viewContext.automaticallyMergesChangesFromParent = true 62 container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 63 64 if !inMemory { 65 // Synchronous, unlike the backfill below: a stranded row shadows 66 // the real game in every `id`-keyed lookup, so leaving a window 67 // where the library is live but the heal hasn't landed means the 68 // user can still open the broken row on this launch. 69 healStrandedSharedGameRows_v1() 70 backfillZoneIdentityFields() 71 backfillCachedSummaryFields() 72 } 73 } 74 75 /// Rebuilds the store empty when an existing one can't be opened against 76 /// the current model — e.g. after an in-place schema change with no 77 /// migration source. The store is *usually* a rebuildable cache of CloudKit 78 /// (the sync engine refetches every record on the next sync), but not 79 /// always: iCloud sync is user-toggleable, making the local store the only 80 /// copy of that user's games, and even with sync on, offline edits may not 81 /// have uploaded yet. The failing files are therefore moved aside under a 82 /// `.broken-<timestamp>` suffix rather than destroyed, so the data stays 83 /// inspectable and recoverable, and the recovery is surfaced through 84 /// diagnostics. 85 private func recreateStore(after originalError: Error) { 86 let coordinator = container.persistentStoreCoordinator 87 var preserved: [String] = [] 88 for description in container.persistentStoreDescriptions { 89 guard let url = description.url else { continue } 90 preserved.append(contentsOf: preserveBrokenStore(at: url)) 91 do { 92 try coordinator.destroyPersistentStore( 93 at: url, 94 ofType: description.type, 95 options: description.options 96 ) 97 } catch { 98 // Best effort — fall through and let the reload attempt report 99 // the real failure if the store truly can't be replaced. 100 } 101 } 102 container.loadPersistentStores { _, retryError in 103 if let retryError { 104 fatalError( 105 "Failed to load Core Data store after reset: \(retryError) " 106 + "(original open error: \(originalError))" 107 ) 108 } 109 } 110 eventLog?.note( 111 "PersistenceController: store load failed; rebuilt empty" 112 + (preserved.isEmpty 113 ? " (no files to preserve)" 114 : " (broken store preserved as \(preserved.joined(separator: ", ")))") 115 + " — \(originalError)", 116 level: "error" 117 ) 118 } 119 120 /// Moves the failing store's files (including the `-wal`/`-shm` sidecars) 121 /// aside before destructive recovery, returning the names of the files it 122 /// preserved. Best effort: a file that can't be moved is left in place for 123 /// `destroyPersistentStore` to clear, so recovery always proceeds. 124 private func preserveBrokenStore(at url: URL) -> [String] { 125 let fileManager = FileManager.default 126 let formatter = DateFormatter() 127 formatter.dateFormat = "yyyyMMdd-HHmmss" 128 formatter.timeZone = TimeZone(secondsFromGMT: 0) 129 let timestamp = formatter.string(from: Date()) 130 var preserved: [String] = [] 131 for suffix in ["", "-wal", "-shm"] { 132 let source = URL(fileURLWithPath: url.path + suffix) 133 guard fileManager.fileExists(atPath: source.path) else { continue } 134 let destination = URL(fileURLWithPath: source.path + ".broken-\(timestamp)") 135 do { 136 try fileManager.moveItem(at: source, to: destination) 137 preserved.append(destination.lastPathComponent) 138 } catch { 139 // Leave the file for destroyPersistentStore. 140 } 141 } 142 return preserved 143 } 144 145 // MARK: - TEMPORARY v1.1 MIGRATION 146 147 /// Repairs `GameEntity` rows stranded by the sync-engine scope bug: while 148 /// `SyncEngine.handleEvent` resolved an engine's database by instance 149 /// identity, a fetch still in flight from an engine that `resetSyncState` 150 /// had just replaced (the account-switch and v4-container purges both 151 /// replace both engines) resolved to *shared*. The private database's zone 152 /// changes then ran the shared branch, which seats a "Joining…" placeholder 153 /// for every newly-visible zone — so every game the user owned got a 154 /// `databaseScope == 1` row carrying the owner placeholder. 155 /// 156 /// Neither scope's `gameIdentityPredicate` can match that row again 157 /// (private wants `ckZoneOwnerName == NIL`, shared wants a concrete owner), 158 /// so the arriving Game record forked a second row instead of filling this 159 /// one in. Two rows then answered to one `id`, and the `fetchLimit = 1` 160 /// lookups in `GameStore.loadGame(id:)` and `movesDiagnostics(for:by:)` 161 /// picked between them unpredictably — an empty row reads as 162 /// `.missingGrid`, surfacing as "Couldn't load puzzle". 163 /// 164 /// Only empty placeholders are touched: a stranded row that carries a 165 /// puzzle is not this bug's work and is left alone. A row with a sibling is 166 /// merged into it and deleted; a row without one is repaired to private 167 /// scope, which is always right here — the branch that produced these rows 168 /// only ever ran over zones the user owns — so the ordinary private sync 169 /// path adopts and fills it. 170 /// 171 /// Gated on a stored flag rather than left to its predicate: unlike the 172 /// non-destructive backfills below this one deletes rows, and a standing 173 /// destructive sweep would be a hazard if some later change ever made this 174 /// row shape legitimate. Remove as one block with 175 /// `strandedSharedRowHealKey`, its call site, and 176 /// `PersistenceControllerHealTests`. 177 private static let strandedSharedRowHealKey = "healStrandedSharedGameRows_v1" 178 179 func healStrandedSharedGameRows_v1(force: Bool = false) { 180 let defaults = UserDefaults.standard 181 if !force, defaults.bool(forKey: Self.strandedSharedRowHealKey) { return } 182 183 let ctx = container.newBackgroundContext() 184 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 185 let summary: String? = ctx.performAndWait { 186 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 187 // `CKCurrentUserDefaultName` is the spelling the placeholder branch 188 // copied off the private zone; nil covers the same shape from 189 // `constructJoinedGame`'s former owner-normalising ternary. 190 req.predicate = NSPredicate( 191 format: "databaseScope == 1" 192 + " AND (ckZoneOwnerName == nil OR ckZoneOwnerName == %@)" 193 + " AND (puzzleSource == nil OR puzzleSource == %@)", 194 CKCurrentUserDefaultName, 195 "" 196 ) 197 guard let stranded = try? ctx.fetch(req), !stranded.isEmpty else { return nil } 198 199 var merged = 0 200 var repaired = 0 201 for row in stranded { 202 guard let id = row.id else { 203 // No domain identity, no puzzle, unmatchable by sync: inert. 204 ctx.delete(row) 205 merged += 1 206 continue 207 } 208 let siblings = NSFetchRequest<GameEntity>(entityName: "GameEntity") 209 siblings.predicate = NSPredicate( 210 format: "id == %@ AND SELF != %@", id as CVarArg, row 211 ) 212 siblings.fetchLimit = 1 213 if let survivor = try? ctx.fetch(siblings).first { 214 Self.adoptChildren(of: row, into: survivor, in: ctx) 215 ctx.delete(row) 216 merged += 1 217 } else { 218 row.databaseScope = 0 219 row.ckZoneOwnerName = nil 220 repaired += 1 221 } 222 } 223 224 guard ctx.hasChanges else { return nil } 225 do { 226 try ctx.save() 227 return "PersistenceController: healed \(stranded.count) stranded shared " 228 + "game row(s) — \(merged) merged, \(repaired) repaired to private" 229 } catch { 230 return "PersistenceController: stranded-row heal save failed — \(error)" 231 } 232 } 233 234 // `force` is the test affordance for driving the pass repeatedly; it 235 // deliberately leaves the shared flag untouched so tests stay hermetic. 236 if !force { defaults.set(true, forKey: Self.strandedSharedRowHealKey) } 237 if let summary { eventLog?.note(summary) } 238 } 239 240 /// Moves a stranded row's irreplaceable children onto the surviving row. 241 /// 242 /// `moves` and `journal` are the synced/durable payload and `players` 243 /// carries local read state, so they are reparented — skipping any whose 244 /// counterpart the survivor already holds, since both rows may have been 245 /// written from the same records. `cells` and `peerChanges` are derived 246 /// caches (replay rebuilds one, the next ledger build the other), so they 247 /// are left to cascade with the deleted row. 248 private nonisolated static func adoptChildren( 249 of row: GameEntity, 250 into survivor: GameEntity, 251 in ctx: NSManagedObjectContext 252 ) { 253 let existingMoves = Set( 254 ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName) 255 ) 256 for child in (row.moves as? Set<MovesEntity>) ?? [] { 257 guard let name = child.ckRecordName, !existingMoves.contains(name) else { 258 ctx.delete(child) 259 continue 260 } 261 child.game = survivor 262 } 263 264 let existingPlayers = Set( 265 ((survivor.players as? Set<PlayerEntity>) ?? []).compactMap(\.ckRecordName) 266 ) 267 for child in (row.players as? Set<PlayerEntity>) ?? [] { 268 guard let name = child.ckRecordName, !existingPlayers.contains(name) else { 269 ctx.delete(child) 270 continue 271 } 272 child.game = survivor 273 } 274 275 // Journal rows have no record name; a device's log is keyed by its 276 // source device and sequence number. 277 let existingJournal = Set( 278 ((survivor.journal as? Set<JournalEntity>) ?? []).map { 279 "\($0.sourceDeviceID ?? "")|\($0.seq)" 280 } 281 ) 282 for child in (row.journal as? Set<JournalEntity>) ?? [] { 283 let key = "\(child.sourceDeviceID ?? "")|\(child.seq)" 284 guard !existingJournal.contains(key) else { 285 ctx.delete(child) 286 continue 287 } 288 child.game = survivor 289 } 290 } 291 292 // MARK: - Backfill 293 294 /// Populates the derived summary columns on rows that never got them. 295 /// 296 /// Chronicles materialized before `Archive.materialize` populated these 297 /// are the population that matters: `GameSummary` papers over the gap by 298 /// reparsing `puzzleSource`, but anything that queries the columns 299 /// directly — the browser's "already in your library" lookup — simply 300 /// can't see those rows. 301 /// 302 /// Self-limiting rather than flag-gated: a populated row no longer matches 303 /// `gridWidth == 0`. A row whose source won't parse stays unmatched and is 304 /// retried on later launches, which is the right outcome for the handful 305 /// of rows that could be in that state. 306 private func backfillCachedSummaryFields() { 307 let bg = container.newBackgroundContext() 308 let eventLog = eventLog 309 bg.perform { 310 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 311 req.predicate = NSPredicate( 312 format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@", 313 "" 314 ) 315 guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } 316 for entity in rows { 317 guard let source = entity.puzzleSource, 318 let xd = try? XD.parse(source) else { continue } 319 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 320 } 321 guard bg.hasChanges else { return } 322 do { 323 try bg.save() 324 } catch { 325 Task { @MainActor in 326 eventLog?.note( 327 "PersistenceController: backfillCachedSummaryFields save failed — \(error)", 328 level: "error" 329 ) 330 } 331 } 332 } 333 } 334 335 /// One-shot pass for `GameEntity` rows written before inbound lookups 336 /// matched on full zone identity (`RecordSerializer.gameIdentityPredicate`). 337 /// Without it, a legacy row is invisible to the new predicate and the next 338 /// fetched record silently spawns a duplicate. Two normalizations: 339 /// a missing `ckZoneName` is derived from `ckRecordName` (game zone and 340 /// record share the `game-<UUID>` spelling), and `ckZoneOwnerName` is 341 /// cleared on private-scope rows — private zones always belong to the 342 /// current user and are matched as `ckZoneOwnerName == NIL`, but rows 343 /// written before that invariant could hold a concrete user-record ID 344 /// when CloudKit round-tripped one instead of the owner placeholder. 345 /// No-ops on every subsequent launch. 346 private func backfillZoneIdentityFields() { 347 let bg = container.newBackgroundContext() 348 let eventLog = eventLog 349 bg.perform { 350 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 351 req.predicate = NSPredicate( 352 format: "(ckRecordName != nil AND ckZoneName == nil) " 353 + "OR (databaseScope == 0 AND ckZoneOwnerName != nil)" 354 ) 355 guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } 356 for entity in rows { 357 if entity.ckZoneName == nil { 358 entity.ckZoneName = entity.ckRecordName 359 } 360 if entity.databaseScope == 0 { 361 entity.ckZoneOwnerName = nil 362 } 363 } 364 if bg.hasChanges { 365 do { 366 try bg.save() 367 } catch { 368 Task { @MainActor in 369 eventLog?.note( 370 "PersistenceController: backfillZoneIdentityFields save failed — \(error)", 371 level: "error" 372 ) 373 } 374 } 375 } 376 } 377 } 378 379 // Loaded once and shared across all container instances so that entity 380 // descriptions are identical objects, which is required for CoreData 381 // relationship type-checking to pass when tests create multiple containers 382 // concurrently. 383 private static let sharedModel: NSManagedObjectModel = { 384 for bundle in Bundle.allBundles + Bundle.allFrameworks { 385 if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"), 386 let model = NSManagedObjectModel(contentsOf: url) { 387 return model 388 } 389 } 390 fatalError("CrossmateModel.momd not found in any loaded bundle") 391 }() 392 }