crossmate

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

commit d11326a55c9990d047b67c046871a62d2bd87c69
parent 45c220c39ad894a415bfe9abe7652be34094fd67
Author: Michael Camilleri <[email protected]>
Date:   Sat, 27 Jun 2026 07:08:52 +0900

Centralise CloudQuery desired key lists

CloudQuery carried repeated desiredKeys arrays for the same CloudKit
record types, which made direct fetches easy to drift from the fields
RecordSerializer expects to parse.

This commit moves the direct-fetch key sets onto RecordSerializer and
has CloudQuery reuse those constants for Game, Moves, Player and Ping
queries. The serializer tests now pin the key sets so future parser
field additions have one obvious place to update.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Sync/CloudQuery.swift | 30+++++++++++++++---------------
MCrossmate/Sync/RecordSerializer.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 41+++++++++++++++++++++++++++++++++++++++++
3 files changed, 104 insertions(+), 15 deletions(-)

diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -70,7 +70,7 @@ extension SyncEngine { database: database, zoneID: zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"] + desiredKeys: RecordSerializer.pingDesiredKeys ) return PerZonePings(records: records, orphanedZone: nil) } catch { @@ -192,7 +192,7 @@ extension SyncEngine { database: database, zoneID: target.zoneID, predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"] + desiredKeys: RecordSerializer.pingDesiredKeys ) return PerZoneInvites( pings: records.compactMap(Ping.parseRecord), @@ -338,7 +338,7 @@ extension SyncEngine { database: database, zoneID: zoneID, predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), - desiredKeys: ["authorID", "kind"] + desiredKeys: RecordSerializer.pingDeletionDesiredKeys ) } catch { await trace( @@ -408,7 +408,7 @@ extension SyncEngine { database: database, zoneID: zone.zoneID, since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] + desiredKeys: RecordSerializer.playerDesiredKeys ) let activities = playerRecords.compactMap { record in Session.parseRecord(record, puzzleTitle: zone.title) @@ -565,7 +565,7 @@ extension SyncEngine { database: database, zoneID: zoneID, since: nil, - desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"] + desiredKeys: RecordSerializer.gameDesiredKeys ) guard !games.isEmpty else { return PerZoneResult(records: [], hasGame: false) @@ -575,14 +575,14 @@ extension SyncEngine { database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + desiredKeys: RecordSerializer.movesDesiredKeys ) async let players = try await self.queryLiveRecords( type: "Player", database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] + desiredKeys: RecordSerializer.playerDesiredKeys ) let (m, p) = try await (moves, players) return PerZoneResult(records: games + m + p, hasGame: true) @@ -675,21 +675,21 @@ extension SyncEngine { do { async let gameResultsTask = database.records( for: [gameRecordID], - desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"] + desiredKeys: RecordSerializer.gameDesiredKeys ) async let movesTask = queryLiveRecords( type: "Moves", database: database, zoneID: info.zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + desiredKeys: RecordSerializer.movesDesiredKeys ) async let playersTask = queryLiveRecords( type: "Player", database: database, zoneID: info.zoneID, since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] + desiredKeys: RecordSerializer.playerDesiredKeys ) (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) } catch { @@ -763,21 +763,21 @@ extension SyncEngine { do { async let gameResultsTask = database.records( for: [gameRecordID], - desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"] + desiredKeys: RecordSerializer.gameDesiredKeys ) async let movesTask = onlyGame ? [] : queryLiveRecords( type: "Moves", database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + desiredKeys: RecordSerializer.movesDesiredKeys ) async let playersTask = onlyGame ? [] : queryLiveRecords( type: "Player", database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] + desiredKeys: RecordSerializer.playerDesiredKeys ) (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) } catch { @@ -882,14 +882,14 @@ extension SyncEngine { ) async let gameResultsTask = database.records( for: [gameRecordID], - desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"] + desiredKeys: RecordSerializer.gameDesiredKeys ) async let movesTask = self.queryLiveRecords( type: "Moves", database: database, zoneID: zone.zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + desiredKeys: RecordSerializer.movesDesiredKeys ) let (gameResults, moves) = try await (gameResultsTask, movesTask) diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -7,6 +7,54 @@ import Foundation /// models and CloudKit `CKRecord` objects. Stateless — all context is passed in. enum RecordSerializer { + // MARK: - Direct fetch key sets + + static let gameDesiredKeys: [CKRecord.FieldKey] = [ + "title", + "completedAt", + "completedBy", + "shareRecordName", + "engagement", + "notification", + "puzzleSource", + ] + + static let movesDesiredKeys: [CKRecord.FieldKey] = [ + "authorID", + "deviceID", + "cells", + "updatedAt", + ] + + static let playerDesiredKeys: [CKRecord.FieldKey] = [ + "authorID", + "name", + "updatedAt", + "selRow", + "selCol", + "selDir", + "readAt", + "readThrough", + "sessionSnapshot", + "timeLog", + "pushAddress", + ] + + static let pingDesiredKeys: [CKRecord.FieldKey] = [ + "authorID", + "deviceID", + "playerName", + "puzzleTitle", + "kind", + "payload", + "addressee", + ] + + static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [ + "authorID", + "kind", + ] + // MARK: - Device identity /// A stable per-device identifier appended to move and snapshot record diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -80,6 +80,47 @@ struct RecordSerializerTests { #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil) } + @Test("Direct fetch key sets include serialized fields") + func directFetchKeySetsIncludeSerializedFields() { + #expect(Set(RecordSerializer.gameDesiredKeys) == [ + "title", + "completedAt", + "completedBy", + "shareRecordName", + "engagement", + "notification", + "puzzleSource", + ]) + #expect(Set(RecordSerializer.movesDesiredKeys) == [ + "authorID", + "deviceID", + "cells", + "updatedAt", + ]) + #expect(Set(RecordSerializer.playerDesiredKeys) == [ + "authorID", + "name", + "updatedAt", + "selRow", + "selCol", + "selDir", + "readAt", + "readThrough", + "sessionSnapshot", + "timeLog", + "pushAddress", + ]) + #expect(Set(RecordSerializer.pingDesiredKeys) == [ + "authorID", + "deviceID", + "playerName", + "puzzleTitle", + "kind", + "payload", + "addressee", + ]) + } + @Test("playerRecord writes readAt and parses it back") func playerRecordReadAtRoundTrip() { let id = UUID()