crossmate

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

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 }