crossmate

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

RecordBuilder.swift (7360B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 extension SyncEngine {
      6     /// Builds the `CKRecord` for a pending change. Uses the zone ID already
      7     /// embedded in the `recordID` — set correctly at enqueue time.
      8     /// `pings` is a snapshot taken from the actor before this is invoked,
      9     /// since the framework calls back synchronously off-actor.
     10     nonisolated func buildRecord(
     11         for recordID: CKRecord.ID,
     12         in ctx: NSManagedObjectContext,
     13         databaseScope: DatabaseScope,
     14         pings: [String: PingPayload],
     15         decisions: [String: String],
     16         decisionVersions: [String: Int64],
     17         decisionSystemFields: [String: Data]
     18     ) -> CKRecord? {
     19         let name = recordID.recordName
     20         let zoneID = recordID.zoneID
     21         if name.hasPrefix("ping-") {
     22             guard let payload = pings[name] else { return nil }
     23             return RecordSerializer.pingRecord(
     24                 gameID: payload.gameID,
     25                 authorID: payload.authorID,
     26                 deviceID: payload.deviceID,
     27                 playerName: payload.playerName,
     28                 puzzleTitle: payload.puzzleTitle,
     29                 eventTimestampMs: payload.eventTimestampMs,
     30                 kind: payload.kind,
     31                 payload: payload.payload,
     32                 addressee: payload.addressee,
     33                 zone: zoneID
     34             )
     35         }
     36         if name.hasPrefix("decision-") {
     37             guard let (kind, key) = RecordSerializer.parseDecisionRecordName(name) else {
     38                 return nil
     39             }
     40             let stateKey = Self.decisionStateKey(recordID)
     41             return RecordSerializer.decisionRecord(
     42                 kind: kind,
     43                 key: key,
     44                 payload: decisions[stateKey],
     45                 zone: zoneID,
     46                 systemFields: decisionSystemFields[stateKey],
     47                 version: decisionVersions[stateKey]
     48             )
     49         }
     50         return ctx.performAndWait {
     51             if name.hasPrefix("game-") {
     52                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     53                 req.predicate = RecordSerializer.gameIdentityPredicate(
     54                     recordName: name,
     55                     zoneID: zoneID,
     56                     databaseScope: databaseScope
     57                 )
     58                 req.fetchLimit = 1
     59                 guard let entity = try? ctx.fetch(req).first else { return nil }
     60                 return RecordSerializer.gameRecord(
     61                     from: entity,
     62                     recordID: recordID,
     63                     includePuzzleSource: entity.ckSystemFields == nil || entity.hasPushPending
     64                 )
     65             } else if name.hasPrefix("moves-") {
     66                 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
     67                 req.predicate = RecordSerializer.gameIdentityPredicate(
     68                     recordName: name,
     69                     zoneID: zoneID,
     70                     databaseScope: databaseScope,
     71                     entityPrefix: "game"
     72                 )
     73                 req.fetchLimit = 1
     74                 guard let entity = try? ctx.fetch(req).first,
     75                       let gameID = entity.game?.id,
     76                       let authorID = entity.authorID,
     77                       let deviceID = entity.deviceID,
     78                       let updatedAt = entity.updatedAt
     79                 else { return nil }
     80                 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
     81                 let value = MovesValue(
     82                     gameID: gameID,
     83                     authorID: authorID,
     84                     deviceID: deviceID,
     85                     cells: cells,
     86                     updatedAt: updatedAt
     87                 )
     88                 return try? RecordSerializer.movesRecord(
     89                     from: value,
     90                     zone: zoneID,
     91                     systemFields: entity.ckSystemFields
     92                 )
     93             } else if name.hasPrefix("journal-") {
     94                 // Reconstruct the upload asset from the durable local journal.
     95                 // `sourceDeviceID == nil` excludes rows cached from other
     96                 // devices for replay (those carry a source key) so we upload
     97                 // only this device's own log. Reading Core Data — not an
     98                 // in-memory stash — means a pending journal save survives an
     99                 // app kill, like Moves/Player.
    100                 guard let (gameID, authorID, deviceID) =
    101                         RecordSerializer.parseJournalRecordName(name)
    102                 else { return nil }
    103                 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
    104                 req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg)
    105                 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
    106                 guard let rows = try? ctx.fetch(req), !rows.isEmpty else { return nil }
    107                 let entries = rows.map(MovesJournal.value(from:))
    108                 let updatedAt = entries.map(\.timestamp).max() ?? Date()
    109                 return try? RecordSerializer.journalRecord(
    110                     gameID: gameID,
    111                     authorID: authorID,
    112                     deviceID: deviceID,
    113                     updatedAt: updatedAt,
    114                     entries: entries,
    115                     zone: zoneID
    116                 )
    117             } else if name.hasPrefix("player-") {
    118                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    119                 req.predicate = RecordSerializer.gameIdentityPredicate(
    120                     recordName: name,
    121                     zoneID: zoneID,
    122                     databaseScope: databaseScope,
    123                     entityPrefix: "game"
    124                 )
    125                 req.fetchLimit = 1
    126                 guard let entity = try? ctx.fetch(req).first,
    127                       let gameID = entity.game?.id,
    128                       let authorID = entity.authorID,
    129                       let renderedName = entity.name,
    130                       let updatedAt = entity.updatedAt
    131                 else { return nil }
    132                 let selection: PlayerSelection?
    133                 if let row = entity.selRow,
    134                    let col = entity.selCol,
    135                    let dir = entity.selDir,
    136                    let direction = Puzzle.Direction(rawValue: dir.intValue) {
    137                     selection = PlayerSelection(
    138                         row: row.intValue,
    139                         col: col.intValue,
    140                         direction: direction
    141                     )
    142                 } else {
    143                     selection = nil
    144                 }
    145                 return RecordSerializer.playerRecord(
    146                     gameID: gameID,
    147                     authorID: authorID,
    148                     name: renderedName,
    149                     updatedAt: updatedAt,
    150                     selection: selection,
    151                     presenceUntil: entity.game?.lastReadOtherMoveAt,
    152                     readThrough: entity.game?.readThroughAt,
    153                     viewedAt: entity.viewedAt,
    154                     timeLog: entity.timeLog,
    155                     pushAddress: entity.pushAddress,
    156                     zone: zoneID,
    157                     systemFields: entity.ckSystemFields
    158                 )
    159             }
    160             return nil
    161         }
    162     }
    163 }