commit 550bb4daa964c57afc62c9200a2c25bddaaf0f0a
parent e1b490ffb40d30342e75902e0ba8341455294a2d
Author: Michael Camilleri <[email protected]>
Date: Wed, 15 Jul 2026 16:03:11 +0900
Add sync protocol versions to games
Games previously had no durable declaration of the merge rules their
participants must share, leaving future protocol changes unable to
prevent an older client from writing incompatible state. This commit
gives each Game an owner-authoritative syncVersion in Core Data and
CloudKit, treats missing values as legacy version 1, and separates the
version assigned to new games from the exact set supported by the app.
Unsupported games remain visible but read-only. Crossmate blocks cell
changes, undo, redo, resignation, and completion, and shows a sticky
'Crossmate Update Required' banner so an incompatible client cannot
publish state that other participants would resolve differently.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
16 files changed, 184 insertions(+), 19 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -110,6 +110,7 @@
701BD90172F500B89E32E8E6 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61768D423564A9080414A38 /* NoticeView.swift */; };
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; };
7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; };
+ 774C5B5FC06366A0F5CD2376 /* GameSyncVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */; };
786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */; };
@@ -304,6 +305,7 @@
23A7390848B1943C80142934 /* PuzzleGridStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGridStats.swift; sourceTree = "<group>"; };
23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColorTests.swift; sourceTree = "<group>"; };
24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GameEntity+ContentKey.swift"; sourceTree = "<group>"; };
+ 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSyncVersion.swift; sourceTree = "<group>"; };
27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; };
@@ -528,6 +530,7 @@
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */,
1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */,
78919F44C3035C48410FC894 /* GamePushCredentials.swift */,
+ 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */,
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */,
86470163BFF956F3DE438506 /* Moves.swift */,
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */,
@@ -1170,6 +1173,7 @@
FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */,
44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
+ 774C5B5FC06366A0F5CD2376 /* GameSyncVersion.swift in Sources */,
1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */,
83CF8376EBFD7271931AC1B7 /* GridAccessibility.swift in Sources */,
4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -933,7 +933,8 @@ private struct PuzzleDisplayView: View {
// a later process never re-fires.
let openState = OpenPuzzleState(
gameID: gameID,
- isAccessRevoked: loadedSession.mutator.isAccessRevoked
+ isAccessRevoked: loadedSession.mutator.isAccessRevoked,
+ isSyncSupported: loadedSession.mutator.isSyncSupported
)
for announcement in OpenPuzzleBanner.announcements(for: openState) {
services.announcements.post(announcement)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -35,6 +35,7 @@
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="shareParticipants" optional="YES" attributeType="String"/>
+ <attribute name="syncVersion" optional="YES" attributeType="Integer 64" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/>
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -44,6 +44,10 @@ final class GameMutator {
/// read-only banner.
var isAccessRevoked: Bool
+ /// Whether this app implements the immutable sync protocol selected by the
+ /// game. Unsupported games remain viewable but every mutation is blocked.
+ var isSyncSupported: Bool
+
/// Set to `true` once the game is completed (won or resigned). A completed
/// game is terminal and read-only: every mutating entry point below becomes
/// a no-op, so the grid can't be edited or "re-solved" after the fact. Set
@@ -65,6 +69,7 @@ final class GameMutator {
isOwned: Bool = true,
isShared: Bool = false,
isAccessRevoked: Bool = false,
+ isSyncSupported: Bool = true,
isCompleted: Bool = false
) {
self.game = game
@@ -77,6 +82,7 @@ final class GameMutator {
self.isOwned = isOwned
self.isShared = isShared
self.isAccessRevoked = isAccessRevoked
+ self.isSyncSupported = isSyncSupported
self.isCompleted = isCompleted
prefetchJournal()
}
@@ -84,7 +90,7 @@ final class GameMutator {
// MARK: - Single-cell mutations
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
- guard !isCompleted else { return }
+ guard !isCompleted, isSyncSupported else { return }
let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
emitMove(
@@ -97,7 +103,7 @@ final class GameMutator {
}
func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
- guard !isCompleted else { return }
+ guard !isCompleted, isSyncSupported else { return }
let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
emitMove(
@@ -137,7 +143,7 @@ final class GameMutator {
// A completed game is read-only. `resignGame` reveals through a freshly
// loaded mutator whose `isCompleted` is still false (it sets
// `completedAt` only afterwards), so its reveal is unaffected.
- guard !isCompleted else { return }
+ guard !isCompleted, isSyncSupported else { return }
let applicable = cells.filter { !$0.isBlock }
guard !applicable.isEmpty else { return }
let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
@@ -174,13 +180,13 @@ final class GameMutator {
/// enabled state of the undo/redo controls.
var canUndo: Bool {
_ = journalRevision
- guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
+ guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return false }
return movesJournal.canUndo(gameID: gameID)
}
var canRedo: Bool {
_ = journalRevision
- guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
+ guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return false }
return movesJournal.canRedo(gameID: gameID)
}
@@ -204,7 +210,7 @@ final class GameMutator {
/// `clear` (no single target) or when nothing was undone.
@discardableResult
func undo() -> CursorLanding? {
- guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
+ guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return nil }
while let plan = movesJournal.planUndo(gameID: gameID) {
if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
@@ -216,7 +222,7 @@ final class GameMutator {
/// Re-applies the most recently undone move. Mirror of `undo()`.
@discardableResult
func redo() -> CursorLanding? {
- guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
+ guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return nil }
while let plan = movesJournal.planRedo(gameID: gameID) {
if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
@@ -314,7 +320,7 @@ final class GameMutator {
targetSeq: Int64? = nil,
direction: Puzzle.Direction? = nil
) {
- guard !isAccessRevoked else { return }
+ guard !isAccessRevoked, isSyncSupported else { return }
let square = game.squares[row][col]
let mark = square.mark
let id = gameID
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -666,6 +666,7 @@ final class GameStore {
/// been written into Core Data by the sync engine.
func refreshCurrentGame() {
guard let game = currentGame, let entity = currentEntity else { return }
+ currentMutator?.isSyncSupported = GameSyncVersion.supports(entity.syncVersion)
// On this path the SyncEngine's inbound fetch has already replayed
// the CellEntity cache atomically with the inbound MovesEntity
// (see SyncEngine.replayCellCache); rewriting it here would do the
@@ -1244,6 +1245,7 @@ final class GameStore {
entity.ckRecordName = "game-\(gameID.uuidString)"
entity.ckZoneName = "game-\(gameID.uuidString)"
entity.databaseScope = 0
+ entity.syncVersion = GameSyncVersion.current
entity.populateCachedSummaryFields(from: puzzle)
try context.save()
@@ -1297,6 +1299,7 @@ final class GameStore {
entity.ckZoneOwnerName =
zoneID.ownerName == CKCurrentUserDefaultName ? nil : zoneID.ownerName
entity.databaseScope = 1
+ entity.syncVersion = GameSyncVersion.legacy
entity.notification = notification
entity.populateCachedSummaryFields(from: puzzle)
if notification != nil {
@@ -1351,6 +1354,9 @@ final class GameStore {
/// Reveals all cells and marks the game as completed (resigned).
func resignGame(id: UUID) throws {
+ guard let existing = fetchGameEntity(id: id),
+ GameSyncVersion.supports(existing.syncVersion)
+ else { return }
let (game, mutator) = try loadGame(id: id)
let allCells = game.puzzle.cells.flatMap { $0 }
mutator.revealCells(allCells)
@@ -1461,7 +1467,9 @@ final class GameStore {
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
guard let entity = try context.fetch(request).first,
- entity.completedAt == nil else { return false }
+ entity.completedAt == nil,
+ GameSyncVersion.supports(entity.syncVersion)
+ else { return false }
entity.completedAt = Date()
// A win: stamp the solver so the Game record can distinguish wins
// from resignations and the completion APN body can name them.
@@ -1945,6 +1953,7 @@ final class GameStore {
entity.ckRecordName = "game-\(gameID.uuidString)"
entity.ckZoneName = "game-\(gameID.uuidString)"
entity.databaseScope = 0
+ entity.syncVersion = GameSyncVersion.current
entity.populateCachedSummaryFields(from: puzzle)
try context.save()
@@ -2904,6 +2913,7 @@ final class GameStore {
isOwned: entity.databaseScope == 0,
isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
isAccessRevoked: entity.isAccessRevoked,
+ isSyncSupported: GameSyncVersion.supports(entity.syncVersion),
isCompleted: entity.completedAt != nil
)
}
diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift
@@ -113,6 +113,22 @@ extension Announcement {
)
}
+ /// The sticky, input-blocking banner shown when a game uses sync semantics
+ /// this app release does not implement. The merged puzzle remains visible,
+ /// but local edits are disabled so this client cannot publish state that
+ /// other participants would resolve differently.
+ static func unsupportedSyncVersion(gameID: UUID) -> Announcement {
+ Announcement(
+ id: "unsupported-sync-version-\(gameID.uuidString)",
+ scope: .game(gameID),
+ severity: .error,
+ title: "Crossmate Update Required",
+ body: "Update Crossmate to make changes to this puzzle.",
+ dismissal: .sticky,
+ blocksInput: true
+ )
+ }
+
/// Reassurance shown on the Game List when a share was accepted but its
/// puzzle had not finished syncing before the join wait timed out. The game
/// surfaces on its own once sync settles, so this clears itself rather than
@@ -135,6 +151,7 @@ extension Announcement {
struct OpenPuzzleState {
let gameID: UUID
let isAccessRevoked: Bool
+ let isSyncSupported: Bool
}
/// A banner that may be (re)posted when a puzzle is opened, reconciled from
@@ -145,6 +162,7 @@ struct OpenPuzzleState {
/// adding a case and its switch arm.
enum OpenPuzzleBanner: CaseIterable {
case accessRevoked
+ case unsupportedSyncVersion
/// The announcement this banner contributes for `state`, or `nil` when
/// the state does not warrant it.
@@ -152,6 +170,8 @@ enum OpenPuzzleBanner: CaseIterable {
switch self {
case .accessRevoked:
state.isAccessRevoked ? .accessRevoked(gameID: state.gameID) : nil
+ case .unsupportedSyncVersion:
+ state.isSyncSupported ? nil : .unsupportedSyncVersion(gameID: state.gameID)
}
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -174,6 +174,7 @@ enum DemoSeed {
game.puzzleResourceID = resourceID
game.createdAt = now
game.updatedAt = now
+ game.syncVersion = GameSyncVersion.current
// A non-nil share record name is what marks the game as shared, which is
// the gate for the Game List participant colour strip; a solo game (no
// participants) leaves it nil.
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -413,6 +413,7 @@ enum Archive {
entity.ckZoneName = zoneID(forOriginalGameID: payload.originalGameID).zoneName
entity.ckZoneOwnerName = nil
entity.databaseScope = 0
+ entity.syncVersion = GameSyncVersion.legacy
entity.title = payload.title
entity.puzzleSource = payload.puzzleSource
entity.completedAt = payload.completedAt
diff --git a/Crossmate/Sync/GameSyncVersion.swift b/Crossmate/Sync/GameSyncVersion.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// Version of the rules used to encode and merge a collaborative game's
+/// durable state. A game keeps the version it was created with; app releases
+/// may support more than one version so existing games remain playable.
+enum GameSyncVersion {
+ /// Games created before the field existed are version 1.
+ static let legacy: Int64 = 1
+
+ /// Version assigned to newly-created games by this app release.
+ static let current: Int64 = legacy
+
+ /// Exact protocol versions this app can safely read and edit.
+ static let supported: Set<Int64> = [legacy]
+
+ /// Core Data test fixtures and pre-migration rows can surface zero before
+ /// the default is materialised. Treat every non-positive value as legacy,
+ /// matching an absent `syncVersion` field on CloudKit records.
+ static func normalized(_ version: Int64) -> Int64 {
+ version > 0 ? version : legacy
+ }
+
+ static func supports(_ version: Int64) -> Bool {
+ supported.contains(normalized(version))
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -17,6 +17,7 @@ enum RecordSerializer {
"roomCredential",
"pushCredential",
"puzzleSource",
+ "syncVersion",
]
// authorID/deviceID are not stored as fields — they're recovered from the
@@ -573,9 +574,9 @@ enum RecordSerializer {
from entity: GameEntity,
includePuzzleSource: Bool
) {
- // `title` and the `shareRecordName` marker are owner-authoritative: the
- // title comes from the puzzle the owner authored, and only owner devices
- // track the share record. A participant only ever re-saves this record to
+ // `title`, `shareRecordName`, and `syncVersion` are owner-authoritative:
+ // the title and protocol come from the game the owner created, and only
+ // owner devices track the share record. A participant only re-saves this record to
// mint the engagement/notification creds below, and at join time its
// local `title` is still the transient "Joining…" placeholder
// (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game
@@ -588,6 +589,7 @@ enum RecordSerializer {
// Owner-side share marker. Propagated so other owner-devices can flip
// their `isShared` flag without reading the zone's CKShare directly.
record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
+ record["syncVersion"] = GameSyncVersion.normalized(entity.syncVersion) as CKRecordValue
}
// Completion is terminal. A metadata-only save from a participant with
// a stale local row must not clear a completion already present in the
@@ -974,6 +976,7 @@ enum RecordSerializer {
// ID carries the concrete user-record ID instead of the placeholder.
entity.ckZoneOwnerName = databaseScope == .private ? nil : zoneID.ownerName
entity.databaseScope = databaseScope.rawValue
+ entity.syncVersion = GameSyncVersion.legacy
entity.title = ""
entity.puzzleSource = ""
entity.createdAt = Date()
@@ -1029,6 +1032,16 @@ enum RecordSerializer {
databaseScope == .private ? nil : record.recordID.zoneID.ownerName
entity.databaseScope = databaseScope.rawValue
+ // The owner chooses an immutable protocol when creating the game.
+ // Participants never write this field, so they can adopt it even while
+ // an unrelated metadata save is pending. An owner with a pending local
+ // save keeps its chosen value until that save round-trips.
+ if databaseScope == .shared || !entity.hasPendingSave {
+ entity.syncVersion = GameSyncVersion.normalized(
+ (record["syncVersion"] as? Int64) ?? GameSyncVersion.legacy
+ )
+ }
+
// Local mutable fields take precedence while a push is in flight.
// The flag is set atomically with the local write (in `markCompleted`,
// `resignGame`, `persistShareName`) and cleared once `SyncEngine`
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1440,6 +1440,7 @@ actor SyncEngine {
entity.ckZoneName = zoneName
entity.ckZoneOwnerName = zoneID.ownerName
entity.databaseScope = 1
+ entity.syncVersion = GameSyncVersion.legacy
entity.title = "Joining\u{2026}"
entity.puzzleSource = ""
entity.createdAt = Date()
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -121,11 +121,12 @@ struct PuzzleView: View {
// when the locally merged grid drifted and no longer reads `.solved`.
private var isSolved: Bool { hasSolved || session.mutator.isCompleted }
- /// Whether a sticky, input-blocking announcement (currently only
- /// access revocation) is showing for this game. Greys out the custom
- /// keyboard and makes the hardware-key handler a no-op.
+ /// Whether the game's protocol is unsupported or a sticky, input-blocking
+ /// announcement is showing. Greys out the custom keyboard and makes the
+ /// hardware-key handler a no-op.
private var isInputBlocked: Bool {
- announcements.isInputBlocked(forGame: session.mutator.gameID)
+ !session.mutator.isSyncSupported
+ || announcements.isInputBlocked(forGame: session.mutator.gameID)
}
private var shouldAutoRevealScoreboard: Bool {
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -71,6 +71,27 @@ struct GameMutatorTests {
#expect(mutator.redo() == nil)
}
+ @Test("An unsupported sync version rejects every mutation")
+ func unsupportedSyncVersionIsReadOnly() throws {
+ let (game, _, _, persistence) = try makeTestGame()
+ let mutator = GameMutator(
+ game: game,
+ gameID: UUID(),
+ movesUpdater: nil,
+ movesJournal: MovesJournal(persistence: persistence),
+ isSyncSupported: false
+ )
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.revealCells([game.puzzle.cells[0][0]])
+
+ #expect(game.squares[0][0].entry == "")
+ #expect(mutator.canUndo == false)
+ #expect(mutator.canRedo == false)
+ #expect(mutator.undo() == nil)
+ #expect(mutator.redo() == nil)
+ }
+
// MARK: - Bulk mutations
@Test("checkCells marks wrong entries via mutator")
diff --git a/Tests/Unit/OpenPuzzleBannerTests.swift b/Tests/Unit/OpenPuzzleBannerTests.swift
@@ -13,7 +13,11 @@ struct OpenPuzzleBannerTests {
func revokedGameYieldsBanner() throws {
let gameID = UUID()
let announcements = OpenPuzzleBanner.announcements(
- for: OpenPuzzleState(gameID: gameID, isAccessRevoked: true)
+ for: OpenPuzzleState(
+ gameID: gameID,
+ isAccessRevoked: true,
+ isSyncSupported: true
+ )
)
#expect(announcements.count == 1)
let banner = try #require(announcements.first)
@@ -27,8 +31,30 @@ struct OpenPuzzleBannerTests {
@Test("Opening a game with access intact yields no banners")
func intactGameYieldsNothing() {
let announcements = OpenPuzzleBanner.announcements(
- for: OpenPuzzleState(gameID: UUID(), isAccessRevoked: false)
+ for: OpenPuzzleState(
+ gameID: UUID(),
+ isAccessRevoked: false,
+ isSyncSupported: true
+ )
)
#expect(announcements.isEmpty)
}
+
+ @Test("Opening an unsupported sync version yields an input-blocking update banner")
+ func unsupportedSyncVersionYieldsBanner() throws {
+ let gameID = UUID()
+ let announcements = OpenPuzzleBanner.announcements(
+ for: OpenPuzzleState(
+ gameID: gameID,
+ isAccessRevoked: false,
+ isSyncSupported: false
+ )
+ )
+ #expect(announcements.count == 1)
+ let banner = try #require(announcements.first)
+ #expect(banner.id == "unsupported-sync-version-\(gameID.uuidString)")
+ #expect(banner.scope == .game(gameID))
+ #expect(banner.dismissal == .sticky)
+ #expect(banner.blocksInput)
+ }
}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -90,6 +90,7 @@ struct RecordSerializerTests {
"roomCredential",
"pushCredential",
"puzzleSource",
+ "syncVersion",
])
#expect(Set(RecordSerializer.movesDesiredKeys) == [
"cells",
@@ -635,6 +636,7 @@ struct RecordSerializerTests {
RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false)
#expect(ownerRecord["title"] as? String == "Joining\u{2026}")
#expect(ownerRecord["shareRecordName"] as? String == "share-marker")
+ #expect(ownerRecord["syncVersion"] as? Int64 == GameSyncVersion.current)
// Participant (databaseScope == 1): the transient placeholder title is
// not written, so a cred-minting re-save can't clobber the owner's title.
@@ -643,6 +645,36 @@ struct RecordSerializerTests {
RecordSerializer.populateGameRecord(participantRecord, from: participantEntity, includePuzzleSource: false)
#expect(participantRecord["title"] == nil)
#expect(participantRecord["shareRecordName"] == nil)
+ #expect(participantRecord["syncVersion"] == nil)
+ }
+
+ @Test("Game records default missing sync versions to legacy and adopt explicit versions")
+ @MainActor func applyGameRecordSyncVersions() {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ let legacyID = UUID()
+ let legacyRecord = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: legacyID),
+ zoneID: RecordSerializer.zoneID(for: legacyID)
+ )
+ )
+ let legacy = RecordSerializer.applyGameRecord(legacyRecord, to: ctx)
+ #expect(legacy.syncVersion == GameSyncVersion.legacy)
+
+ let futureID = UUID()
+ let futureRecord = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: futureID),
+ zoneID: RecordSerializer.zoneID(for: futureID)
+ )
+ )
+ futureRecord["syncVersion"] = Int64(7) as CKRecordValue
+ let future = RecordSerializer.applyGameRecord(futureRecord, to: ctx)
+ #expect(future.syncVersion == 7)
}
@Test("applyGameRecord preserves local mutable fields when a save is pending")
diff --git a/cloudkit.ckdb b/cloudkit.ckdb
@@ -49,6 +49,7 @@ DEFINE SCHEMA
pushCredential BYTES,
roomCredential BYTES,
shareRecordName STRING,
+ syncVersion INT64,
title STRING,
GRANT WRITE TO "_creator",
GRANT CREATE TO "_icloud",