commit f86caf34c9679a8a0823640fc1bd9f286763dc86
parent f60bdf11b732e343fa50696974e36f19c57fa516
Author: Michael Camilleri <[email protected]>
Date: Mon, 29 Jun 2026 13:10:49 +0900
Keep completed games terminal across Game record sync
Completed shared puzzles could reappear as in-progress when a stale
participant device saved Game-record metadata after first restoring an
old share. The previous serializer wrote nil completion fields from that
local row and also accepted an inbound nil `completedAt` over an already
completed entity, so notification or engagement updates could undo the
terminal completion latch.
This commit makes Game completion monotonic during sync. Outbound
metadata saves preserve any completion already present on the restored
server record unless the local entity has a non-nil completion to write,
and inbound Game records no longer clear an existing local completion
when the incoming snapshot lacks one. Notification and engagement
metadata still converge normally, but nil can no longer resurrect a
completed puzzle.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -383,10 +383,14 @@ enum RecordSerializer {
// their `isShared` flag without reading the zone's CKShare directly.
record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
}
- record["completedAt"] = entity.completedAt as CKRecordValue?
- // Solver's authorID on a win; nil for a resignation. Single-writer
- // (the device that first completes the game) so plain LWW is safe.
- record["completedBy"] = entity.completedBy 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
+ // restored server record.
+ if let completedAt = entity.completedAt {
+ record["completedAt"] = completedAt as CKRecordValue
+ // Solver's authorID on a win; nil for a resignation.
+ record["completedBy"] = entity.completedBy as CKRecordValue?
+ }
// The shared live-engagement room credentials (encoded
// EngagementRoomCredentials). Any present participant may mint these
// when the field is empty; convergence is plain record-level LWW, and
@@ -813,8 +817,14 @@ enum RecordSerializer {
// Replay's strict completeness would then wait on it forever. Signal
// the transition so the caller can enqueue the upload.
let wasCompleted = entity.completedAt != nil
- entity.completedAt = record["completedAt"] as? Date
- entity.completedBy = record["completedBy"] as? String
+ let incomingCompletedAt = record["completedAt"] as? Date
+ if let incomingCompletedAt {
+ entity.completedAt = incomingCompletedAt
+ entity.completedBy = record["completedBy"] as? String
+ } else if entity.completedAt == nil {
+ entity.completedAt = nil
+ entity.completedBy = nil
+ }
if !wasCompleted, entity.completedAt != nil, let id = entity.id {
onCompletedTransition?(id)
}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -608,6 +608,66 @@ struct RecordSerializerTests {
#expect(merged.ckSystemFields != nil)
}
+ @Test("applyGameRecord does not clear an existing completion with an incomplete snapshot")
+ @MainActor func applyGameRecordKeepsExistingCompletionWhenIncomingIsNil() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ let zone = RecordSerializer.zoneID(for: gameID)
+ let recordName = RecordSerializer.recordName(forGameID: gameID)
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
+ let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500)
+
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.ckRecordName = recordName
+ entity.title = "Completed"
+ entity.completedAt = localCompletedAt
+ entity.completedBy = "alice"
+ entity.puzzleSource = ""
+ entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000)
+ entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400)
+ try ctx.save()
+
+ let record = CKRecord(recordType: "Game", recordID: recordID)
+ record["notification"] = "fresh-notification" as CKRecordValue
+
+ let merged = RecordSerializer.applyGameRecord(record, to: ctx)
+ try ctx.save()
+
+ #expect(merged === entity)
+ #expect(merged.completedAt == localCompletedAt)
+ #expect(merged.completedBy == "alice")
+ #expect(merged.notification == "fresh-notification")
+ }
+
+ @Test("populateGameRecord preserves server completion when local participant is stale")
+ @MainActor func populateGameRecordDoesNotClearServerCompletionFromStaleParticipant() throws {
+ let gameID = UUID()
+ let zone = RecordSerializer.zoneID(for: gameID, ownerName: "_owner")
+ let recordID = CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: zone
+ )
+ let serverCompletedAt = Date(timeIntervalSince1970: 1_700_000_600)
+ let record = CKRecord(recordType: "Game", recordID: recordID)
+ record["completedAt"] = serverCompletedAt as CKRecordValue
+ record["completedBy"] = "owner-author" as CKRecordValue
+
+ let persistence = makeTestPersistence()
+ let entity = GameEntity(context: persistence.viewContext)
+ entity.id = gameID
+ entity.ckRecordName = recordID.recordName
+ entity.databaseScope = 1
+ entity.notification = "new-notification"
+
+ RecordSerializer.populateGameRecord(record, from: entity, includePuzzleSource: false)
+
+ #expect(record["completedAt"] as? Date == serverCompletedAt)
+ #expect(record["completedBy"] as? String == "owner-author")
+ #expect(record["notification"] as? String == "new-notification")
+ }
+
@Test("applyGameRecord does not lower an existing updatedAt")
@MainActor func applyGameRecordPreservesFresherUpdatedAt() throws {
let persistence = makeTestPersistence()