crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }