PersistenceController.swift (16259B)
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 } 72 } 73 74 /// Rebuilds the store empty when an existing one can't be opened against 75 /// the current model — e.g. after an in-place schema change with no 76 /// migration source. The store is *usually* a rebuildable cache of CloudKit 77 /// (the sync engine refetches every record on the next sync), but not 78 /// always: iCloud sync is user-toggleable, making the local store the only 79 /// copy of that user's games, and even with sync on, offline edits may not 80 /// have uploaded yet. The failing files are therefore moved aside under a 81 /// `.broken-<timestamp>` suffix rather than destroyed, so the data stays 82 /// inspectable and recoverable, and the recovery is surfaced through 83 /// diagnostics. 84 private func recreateStore(after originalError: Error) { 85 let coordinator = container.persistentStoreCoordinator 86 var preserved: [String] = [] 87 for description in container.persistentStoreDescriptions { 88 guard let url = description.url else { continue } 89 preserved.append(contentsOf: preserveBrokenStore(at: url)) 90 do { 91 try coordinator.destroyPersistentStore( 92 at: url, 93 ofType: description.type, 94 options: description.options 95 ) 96 } catch { 97 // Best effort — fall through and let the reload attempt report 98 // the real failure if the store truly can't be replaced. 99 } 100 } 101 container.loadPersistentStores { _, retryError in 102 if let retryError { 103 fatalError( 104 "Failed to load Core Data store after reset: \(retryError) " 105 + "(original open error: \(originalError))" 106 ) 107 } 108 } 109 eventLog?.note( 110 "PersistenceController: store load failed; rebuilt empty" 111 + (preserved.isEmpty 112 ? " (no files to preserve)" 113 : " (broken store preserved as \(preserved.joined(separator: ", ")))") 114 + " — \(originalError)", 115 level: "error" 116 ) 117 } 118 119 /// Moves the failing store's files (including the `-wal`/`-shm` sidecars) 120 /// aside before destructive recovery, returning the names of the files it 121 /// preserved. Best effort: a file that can't be moved is left in place for 122 /// `destroyPersistentStore` to clear, so recovery always proceeds. 123 private func preserveBrokenStore(at url: URL) -> [String] { 124 let fileManager = FileManager.default 125 let formatter = DateFormatter() 126 formatter.dateFormat = "yyyyMMdd-HHmmss" 127 formatter.timeZone = TimeZone(secondsFromGMT: 0) 128 let timestamp = formatter.string(from: Date()) 129 var preserved: [String] = [] 130 for suffix in ["", "-wal", "-shm"] { 131 let source = URL(fileURLWithPath: url.path + suffix) 132 guard fileManager.fileExists(atPath: source.path) else { continue } 133 let destination = URL(fileURLWithPath: source.path + ".broken-\(timestamp)") 134 do { 135 try fileManager.moveItem(at: source, to: destination) 136 preserved.append(destination.lastPathComponent) 137 } catch { 138 // Leave the file for destroyPersistentStore. 139 } 140 } 141 return preserved 142 } 143 144 // MARK: - TEMPORARY v1.1 MIGRATION 145 146 /// Repairs `GameEntity` rows stranded by the sync-engine scope bug: while 147 /// `SyncEngine.handleEvent` resolved an engine's database by instance 148 /// identity, a fetch still in flight from an engine that `resetSyncState` 149 /// had just replaced (the account-switch and v4-container purges both 150 /// replace both engines) resolved to *shared*. The private database's zone 151 /// changes then ran the shared branch, which seats a "Joining…" placeholder 152 /// for every newly-visible zone — so every game the user owned got a 153 /// `databaseScope == 1` row carrying the owner placeholder. 154 /// 155 /// Neither scope's `gameIdentityPredicate` can match that row again 156 /// (private wants `ckZoneOwnerName == NIL`, shared wants a concrete owner), 157 /// so the arriving Game record forked a second row instead of filling this 158 /// one in. Two rows then answered to one `id`, and the `fetchLimit = 1` 159 /// lookups in `GameStore.loadGame(id:)` and `movesDiagnostics(for:by:)` 160 /// picked between them unpredictably — an empty row reads as 161 /// `.missingGrid`, surfacing as "Couldn't load puzzle". 162 /// 163 /// Only empty placeholders are touched: a stranded row that carries a 164 /// puzzle is not this bug's work and is left alone. A row with a sibling is 165 /// merged into it and deleted; a row without one is repaired to private 166 /// scope, which is always right here — the branch that produced these rows 167 /// only ever ran over zones the user owns — so the ordinary private sync 168 /// path adopts and fills it. 169 /// 170 /// Gated on a stored flag rather than left to its predicate: unlike the 171 /// non-destructive backfills below this one deletes rows, and a standing 172 /// destructive sweep would be a hazard if some later change ever made this 173 /// row shape legitimate. Remove as one block with 174 /// `strandedSharedRowHealKey`, its call site, and 175 /// `PersistenceControllerHealTests`. 176 private static let strandedSharedRowHealKey = "healStrandedSharedGameRows_v1" 177 178 func healStrandedSharedGameRows_v1(force: Bool = false) { 179 let defaults = UserDefaults.standard 180 if !force, defaults.bool(forKey: Self.strandedSharedRowHealKey) { return } 181 182 let ctx = container.newBackgroundContext() 183 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 184 let summary: String? = ctx.performAndWait { 185 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 186 // `CKCurrentUserDefaultName` is the spelling the placeholder branch 187 // copied off the private zone; nil covers the same shape from 188 // `constructJoinedGame`'s former owner-normalising ternary. 189 req.predicate = NSPredicate( 190 format: "databaseScope == 1" 191 + " AND (ckZoneOwnerName == nil OR ckZoneOwnerName == %@)" 192 + " AND (puzzleSource == nil OR puzzleSource == %@)", 193 CKCurrentUserDefaultName, 194 "" 195 ) 196 guard let stranded = try? ctx.fetch(req), !stranded.isEmpty else { return nil } 197 198 var merged = 0 199 var repaired = 0 200 for row in stranded { 201 guard let id = row.id else { 202 // No domain identity, no puzzle, unmatchable by sync: inert. 203 ctx.delete(row) 204 merged += 1 205 continue 206 } 207 let siblings = NSFetchRequest<GameEntity>(entityName: "GameEntity") 208 siblings.predicate = NSPredicate( 209 format: "id == %@ AND SELF != %@", id as CVarArg, row 210 ) 211 siblings.fetchLimit = 1 212 if let survivor = try? ctx.fetch(siblings).first { 213 Self.adoptChildren(of: row, into: survivor, in: ctx) 214 ctx.delete(row) 215 merged += 1 216 } else { 217 row.databaseScope = 0 218 row.ckZoneOwnerName = nil 219 repaired += 1 220 } 221 } 222 223 guard ctx.hasChanges else { return nil } 224 do { 225 try ctx.save() 226 return "PersistenceController: healed \(stranded.count) stranded shared " 227 + "game row(s) — \(merged) merged, \(repaired) repaired to private" 228 } catch { 229 return "PersistenceController: stranded-row heal save failed — \(error)" 230 } 231 } 232 233 // `force` is the test affordance for driving the pass repeatedly; it 234 // deliberately leaves the shared flag untouched so tests stay hermetic. 235 if !force { defaults.set(true, forKey: Self.strandedSharedRowHealKey) } 236 if let summary { eventLog?.note(summary) } 237 } 238 239 /// Moves a stranded row's irreplaceable children onto the surviving row. 240 /// 241 /// `moves` and `journal` are the synced/durable payload and `players` 242 /// carries local read state, so they are reparented — skipping any whose 243 /// counterpart the survivor already holds, since both rows may have been 244 /// written from the same records. `cells` and `peerChanges` are derived 245 /// caches (replay rebuilds one, the next ledger build the other), so they 246 /// are left to cascade with the deleted row. 247 private nonisolated static func adoptChildren( 248 of row: GameEntity, 249 into survivor: GameEntity, 250 in ctx: NSManagedObjectContext 251 ) { 252 let existingMoves = Set( 253 ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName) 254 ) 255 for child in (row.moves as? Set<MovesEntity>) ?? [] { 256 guard let name = child.ckRecordName, !existingMoves.contains(name) else { 257 ctx.delete(child) 258 continue 259 } 260 child.game = survivor 261 } 262 263 let existingPlayers = Set( 264 ((survivor.players as? Set<PlayerEntity>) ?? []).compactMap(\.ckRecordName) 265 ) 266 for child in (row.players as? Set<PlayerEntity>) ?? [] { 267 guard let name = child.ckRecordName, !existingPlayers.contains(name) else { 268 ctx.delete(child) 269 continue 270 } 271 child.game = survivor 272 } 273 274 // Journal rows have no record name; a device's log is keyed by its 275 // source device and sequence number. 276 let existingJournal = Set( 277 ((survivor.journal as? Set<JournalEntity>) ?? []).map { 278 "\($0.sourceDeviceID ?? "")|\($0.seq)" 279 } 280 ) 281 for child in (row.journal as? Set<JournalEntity>) ?? [] { 282 let key = "\(child.sourceDeviceID ?? "")|\(child.seq)" 283 guard !existingJournal.contains(key) else { 284 ctx.delete(child) 285 continue 286 } 287 child.game = survivor 288 } 289 } 290 291 // MARK: - Backfill 292 293 /// One-shot pass for `GameEntity` rows written before inbound lookups 294 /// matched on full zone identity (`RecordSerializer.gameIdentityPredicate`). 295 /// Without it, a legacy row is invisible to the new predicate and the next 296 /// fetched record silently spawns a duplicate. Two normalizations: 297 /// a missing `ckZoneName` is derived from `ckRecordName` (game zone and 298 /// record share the `game-<UUID>` spelling), and `ckZoneOwnerName` is 299 /// cleared on private-scope rows — private zones always belong to the 300 /// current user and are matched as `ckZoneOwnerName == NIL`, but rows 301 /// written before that invariant could hold a concrete user-record ID 302 /// when CloudKit round-tripped one instead of the owner placeholder. 303 /// No-ops on every subsequent launch. 304 private func backfillZoneIdentityFields() { 305 let bg = container.newBackgroundContext() 306 bg.perform { 307 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 308 req.predicate = NSPredicate( 309 format: "(ckRecordName != nil AND ckZoneName == nil) " 310 + "OR (databaseScope == 0 AND ckZoneOwnerName != nil)" 311 ) 312 guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } 313 for entity in rows { 314 if entity.ckZoneName == nil { 315 entity.ckZoneName = entity.ckRecordName 316 } 317 if entity.databaseScope == 0 { 318 entity.ckZoneOwnerName = nil 319 } 320 } 321 if bg.hasChanges { 322 do { 323 try bg.save() 324 } catch { 325 Task { @MainActor [weak self] in 326 self?.eventLog?.note( 327 "PersistenceController: backfillZoneIdentityFields save failed — \(error)", 328 level: "error" 329 ) 330 } 331 } 332 } 333 } 334 } 335 336 // Loaded once and shared across all container instances so that entity 337 // descriptions are identical objects, which is required for CoreData 338 // relationship type-checking to pass when tests create multiple containers 339 // concurrently. 340 private static let sharedModel: NSManagedObjectModel = { 341 for bundle in Bundle.allBundles + Bundle.allFrameworks { 342 if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"), 343 let model = NSManagedObjectModel(contentsOf: url) { 344 return model 345 } 346 } 347 fatalError("CrossmateModel.momd not found in any loaded bundle") 348 }() 349 }