crossmate

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

commit 9013cf6e8c5b3a40ab83fa2bff9d75530f551488
parent 238812f5d75f66507d4d246f80e35385254d32f4
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 14:16:33 +0900

Stabilise equal-timestamp Moves cell adoption

This commit stops inbound Moves snapshots from letting fetch order
decide same-cell conflicts when the cell timestamps are identical. The
realtime path can cache a row before the durable CloudKit copy arrives,
and the previous merge treated an equal timestamp as permission for the
incoming cell to overwrite the cached value.

Now mergeIncomingMovesCells keeps the existing cell on an exact tie and
still adopts genuinely newer cells. Because the helper merges into one
author/device row keyed by ckRecordName, this matches GridStateMerger's
deterministic tie-break for the equal-writer, equal-device case.

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

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 2+-
MTests/Unit/Sync/MovesInboundTests.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 60 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -1054,7 +1054,7 @@ enum RecordSerializer { var cells = existing for (position, incomingCell) in incoming { if let existingCell = cells[position], - existingCell.updatedAt > incomingCell.updatedAt { + existingCell.updatedAt >= incomingCell.updatedAt { continue } cells[position] = incomingCell diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift @@ -361,6 +361,65 @@ struct MovesInboundTests { #expect(decoded[GridPosition(row: 2, col: 2)]?.letter == "C") } + @Test("Inbound other-device record keeps existing cells on equal timestamps") + func inboundOtherDeviceRecordKeepsExistingCellsOnEqualTimestamps() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let game = GameEntity(context: ctx) + game.id = gameID + game.ckRecordName = "game-\(gameID.uuidString)" + game.title = "" + game.puzzleSource = "" + game.createdAt = Date(timeIntervalSince1970: 0) + game.updatedAt = Date(timeIntervalSince1970: 20) + + let same = Date(timeIntervalSince1970: 20) + let position = GridPosition(row: 0, col: 0) + let cachedCells: [GridPosition: TimestampedCell] = [ + position: TimestampedCell( + letter: "B", mark: .none, updatedAt: same, authorID: "bob" + ), + ] + let cached = MovesEntity(context: ctx) + cached.game = game + cached.ckRecordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: "bob", + deviceID: "phone" + ) + cached.authorID = "bob" + cached.deviceID = "phone" + cached.updatedAt = same + cached.cells = try MovesCodec.encode(cachedCells) + try ctx.save() + + let serverCells: [GridPosition: TimestampedCell] = [ + position: TimestampedCell( + letter: "A", mark: .none, updatedAt: same, authorID: "bob" + ), + ] + let (rec, value) = try record( + in: ctx, + authorID: "bob", + deviceID: "phone", + cells: serverCells, + updatedAt: same + ) + + RecordSerializer.applyMovesRecord( + rec, + value: value, + to: ctx, + localAuthorID: "alice" + ) + + let row = try #require(fetchAll(ctx).first) + let decoded = try MovesCodec.decode(row.cells ?? Data()) + #expect(decoded[position]?.letter == "B") + #expect(decoded[position]?.updatedAt == same) + } + @Test("Two devices for the same game produce two distinct rows") func twoDevicesYieldTwoRows() throws { let persistence = makeTestPersistence()