PersistenceController.swift (3276B)
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 init(inMemory: Bool = false) { 16 container = NSPersistentContainer( 17 name: "CrossmateModel", 18 managedObjectModel: Self.sharedModel 19 ) 20 21 if inMemory { 22 // NSInMemoryStoreType keeps each store fully isolated in process 23 // memory with no file involvement, which prevents concurrent test 24 // runs from colliding through shared SQLite WAL files at /dev/null. 25 let description = NSPersistentStoreDescription() 26 description.type = NSInMemoryStoreType 27 container.persistentStoreDescriptions = [description] 28 } 29 30 container.loadPersistentStores { _, error in 31 if let error { 32 // Failing to open the store is unrecoverable for the app. 33 // Crash loudly so we notice in development rather than 34 // silently running with no persistence. 35 fatalError("Failed to load Core Data store: \(error)") 36 } 37 } 38 39 container.viewContext.automaticallyMergesChangesFromParent = true 40 41 if !inMemory { 42 backfillCachedSummaryFields() 43 } 44 } 45 46 /// One-shot pass for `GameEntity` rows created before cached summary 47 /// fields were wired into the creation paths. Runs off the main thread, 48 /// no-ops on every subsequent launch. 49 private func backfillCachedSummaryFields() { 50 let bg = container.newBackgroundContext() 51 bg.perform { 52 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 53 req.predicate = NSPredicate( 54 format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@", 55 "" 56 ) 57 guard let rows = try? bg.fetch(req), !rows.isEmpty else { return } 58 for entity in rows { 59 guard let source = entity.puzzleSource, 60 let xd = try? XD.parse(source) else { continue } 61 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 62 } 63 if bg.hasChanges { try? bg.save() } 64 } 65 } 66 67 // Loaded once and shared across all container instances so that entity 68 // descriptions are identical objects, which is required for CoreData 69 // relationship type-checking to pass when tests create multiple containers 70 // concurrently. 71 private static let sharedModel: NSManagedObjectModel = { 72 for bundle in Bundle.allBundles + Bundle.allFrameworks { 73 if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"), 74 let model = NSManagedObjectModel(contentsOf: url) { 75 return model 76 } 77 } 78 fatalError("CrossmateModel.momd not found in any loaded bundle") 79 }() 80 }