crossmate

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

commit c5b5e6280d4b6b1100679b6d015f5835b89b5b6c
parent 66547492b54fbf359c3d21b8b8f8b7b87958d3cf
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 14:35:37 +0900

Avoid updated timestamps in Game records being overwritten with earlier timestamps

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 12+++++++++---
MTests/Unit/RecordSerializerTests.swift | 29+++++++++++++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -342,12 +342,18 @@ enum RecordSerializer { } // Seed createdAt/updatedAt from the server record so the library - // can order newly-arrived games. The CKRecord timestamps are the - // source of truth when we don't have a local creation event. + // can order newly-arrived games. After that, Game records may lag + // behind fresher Moves timestamps, so never move updatedAt backward. if entity.createdAt == nil { entity.createdAt = record.creationDate ?? Date() } - entity.updatedAt = record.modificationDate ?? entity.updatedAt ?? Date() + if let modificationDate = record.modificationDate { + if entity.updatedAt.map({ $0 < modificationDate }) ?? true { + entity.updatedAt = modificationDate + } + } else if entity.updatedAt == nil { + entity.updatedAt = Date() + } entity.ckRecordName = recordName entity.ckSystemFields = encodeSystemFields(of: record) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -135,6 +135,35 @@ struct RecordSerializerTests { #expect(merged.title == "Updated") // mutable field updated } + @Test("applyGameRecord does not lower an existing updatedAt") + @MainActor func applyGameRecordPreservesFresherUpdatedAt() 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 entity = GameEntity(context: ctx) + let newerUpdatedAt = Date(timeIntervalSince1970: 1_700_000_500) + entity.id = gameID + entity.ckRecordName = recordName + entity.title = "Local" + entity.puzzleSource = "" + entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) + entity.updatedAt = newerUpdatedAt + + let record = CKRecord(recordType: "Game", recordID: recordID) + record["title"] = "Remote" as CKRecordValue + + let merged = RecordSerializer.applyGameRecord(record, to: ctx) + try ctx.save() + + #expect(merged === entity) + #expect(merged.title == "Remote") + #expect(merged.updatedAt == newerUpdatedAt) + } + // MARK: - System fields round-trip @Test("Encode and decode system fields preserves record type and zone")