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 }