PersistenceController.swift (8231B)
1 import CoreData 2 import Foundation 3 4 /// Wraps the app's `NSPersistentContainer`. Plain Core Data with no 5 /// CloudKit mirroring — sync (single-user iPhone↔iPad and CKShare 6 /// collaboration alike) is the job of a separate sync engine that will 7 /// drive CloudKit directly on top of this same store. See PLAN.md for the 8 /// layered design. 9 @MainActor 10 final class PersistenceController { 11 let container: NSPersistentContainer 12 13 var viewContext: NSManagedObjectContext { container.viewContext } 14 15 private let eventLog: EventLog? 16 17 init(inMemory: Bool = false, storeURL: URL? = nil, eventLog: EventLog? = nil) { 18 self.eventLog = eventLog 19 container = NSPersistentContainer( 20 name: "CrossmateModel", 21 managedObjectModel: Self.sharedModel 22 ) 23 24 if inMemory { 25 // NSInMemoryStoreType keeps each store fully isolated in process 26 // memory with no file involvement, which prevents concurrent test 27 // runs from colliding through shared SQLite WAL files at /dev/null. 28 let description = NSPersistentStoreDescription() 29 description.type = NSInMemoryStoreType 30 container.persistentStoreDescriptions = [description] 31 } else { 32 // The app always uses the container's default location; tests 33 // point the store at a throwaway URL to exercise the on-disk 34 // load/recovery path without touching real data. 35 if let storeURL { 36 container.persistentStoreDescriptions = [ 37 NSPersistentStoreDescription(url: storeURL) 38 ] 39 } 40 // Enable lightweight migration so additive schema changes — and 41 // attribute renames carrying a `renamingIdentifier` in the model 42 // — apply on launch without a hand-written mapping. A non-additive 43 // change made in place (no prior model version kept as a migration 44 // source) can't be inferred; `recreateStore(after:)` handles that 45 // by discarding and rebuilding the store. 46 for description in container.persistentStoreDescriptions { 47 description.shouldMigrateStoreAutomatically = true 48 description.shouldInferMappingModelAutomatically = true 49 } 50 } 51 52 container.loadPersistentStores { [self] _, error in 53 guard let error else { return } 54 if inMemory { 55 fatalError("Failed to load in-memory Core Data store: \(error)") 56 } 57 recreateStore(after: error) 58 } 59 60 container.viewContext.automaticallyMergesChangesFromParent = true 61 container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 62 63 if !inMemory { 64 backfillCachedSummaryFields() 65 } 66 } 67 68 /// Rebuilds the store empty when an existing one can't be opened against 69 /// the current model — e.g. after an in-place schema change with no 70 /// migration source. The store is *usually* a rebuildable cache of CloudKit 71 /// (the sync engine refetches every record on the next sync), but not 72 /// always: iCloud sync is user-toggleable, making the local store the only 73 /// copy of that user's games, and even with sync on, offline edits may not 74 /// have uploaded yet. The failing files are therefore moved aside under a 75 /// `.broken-<timestamp>` suffix rather than destroyed, so the data stays 76 /// inspectable and recoverable, and the recovery is surfaced through 77 /// diagnostics. 78 private func recreateStore(after originalError: Error) { 79 let coordinator = container.persistentStoreCoordinator 80 var preserved: [String] = [] 81 for description in container.persistentStoreDescriptions { 82 guard let url = description.url else { continue } 83 preserved.append(contentsOf: preserveBrokenStore(at: url)) 84 do { 85 try coordinator.destroyPersistentStore( 86 at: url, 87 ofType: description.type, 88 options: description.options 89 ) 90 } catch { 91 // Best effort — fall through and let the reload attempt report 92 // the real failure if the store truly can't be replaced. 93 } 94 } 95 container.loadPersistentStores { _, retryError in 96 if let retryError { 97 fatalError( 98 "Failed to load Core Data store after reset: \(retryError) " 99 + "(original open error: \(originalError))" 100 ) 101 } 102 } 103 eventLog?.note( 104 "PersistenceController: store load failed; rebuilt empty" 105 + (preserved.isEmpty 106 ? " (no files to preserve)" 107 : " (broken store preserved as \(preserved.joined(separator: ", ")))") 108 + " — \(originalError)", 109 level: "error" 110 ) 111 } 112 113 /// Moves the failing store's files (including the `-wal`/`-shm` sidecars) 114 /// aside before destructive recovery, returning the names of the files it 115 /// preserved. Best effort: a file that can't be moved is left in place for 116 /// `destroyPersistentStore` to clear, so recovery always proceeds. 117 private func preserveBrokenStore(at url: URL) -> [String] { 118 let fileManager = FileManager.default 119 let formatter = DateFormatter() 120 formatter.dateFormat = "yyyyMMdd-HHmmss" 121 formatter.timeZone = TimeZone(secondsFromGMT: 0) 122 let timestamp = formatter.string(from: Date()) 123 var preserved: [String] = [] 124 for suffix in ["", "-wal", "-shm"] { 125 let source = URL(fileURLWithPath: url.path + suffix) 126 guard fileManager.fileExists(atPath: source.path) else { continue } 127 let destination = URL(fileURLWithPath: source.path + ".broken-\(timestamp)") 128 do { 129 try fileManager.moveItem(at: source, to: destination) 130 preserved.append(destination.lastPathComponent) 131 } catch { 132 // Leave the file for destroyPersistentStore. 133 } 134 } 135 return preserved 136 } 137 138 /// One-shot pass for `GameEntity` rows created before cached summary 139 /// fields were wired into the creation paths. Runs off the main thread, 140 /// no-ops on every subsequent launch. 141 private func backfillCachedSummaryFields() { 142 let bg = container.newBackgroundContext() 143 bg.perform { 144 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 145 req.predicate = NSPredicate( 146 format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@", 147 "" 148 ) 149 guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } 150 for entity in rows { 151 guard let source = entity.puzzleSource, 152 let xd = try? XD.parse(source) else { continue } 153 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 154 } 155 if bg.hasChanges { 156 do { 157 try bg.save() 158 } catch { 159 Task { @MainActor [weak self] in 160 self?.eventLog?.note( 161 "PersistenceController: backfillCachedSummaryFields save failed — \(error)", 162 level: "error" 163 ) 164 } 165 } 166 } 167 } 168 } 169 170 // Loaded once and shared across all container instances so that entity 171 // descriptions are identical objects, which is required for CoreData 172 // relationship type-checking to pass when tests create multiple containers 173 // concurrently. 174 private static let sharedModel: NSManagedObjectModel = { 175 for bundle in Bundle.allBundles + Bundle.allFrameworks { 176 if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"), 177 let model = NSManagedObjectModel(contentsOf: url) { 178 return model 179 } 180 } 181 fatalError("CrossmateModel.momd not found in any loaded bundle") 182 }() 183 }