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 }