crossmate

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

PersistenceController.swift (18144B)


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