crossmate

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

RecordBuilder.swift (6869B)


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