crossmate

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

commit 6982ae31db03498897daa4d519bdf9fb95031ea7
parent a80f4ff678caa09eb27351d910d759432306fe5d
Author: Michael Camilleri <[email protected]>
Date:   Sat,  9 May 2026 09:19:39 +0900

Preserve local Moves record at all times

Direct push fetches can re-deliver this device's older server-side Moves record
while newer local edits are still queued for upload. This commit keeps local
value state authoritative for the current author/device row while still
adopting CloudKit system fields, and continues to replace cached rows for other
devices normally.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 4++++
MCrossmate/Sync/RecordSerializer.swift | 14+++++++++++++-
MCrossmate/Sync/SyncEngine.swift | 28++++++++++++++++++++++++++--
MTests/Unit/Sync/MovesInboundTests.swift | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 166 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -145,6 +145,10 @@ final class AppServices { syncMonitor.note(message) } + await syncEngine.setLocalAuthorIDProvider { [identity] in + identity.currentID + } + await syncEngine.setOnRemoteMovesUpdated { [store, identity] gameIDs in store.noteIncomingMovesUpdate( gameIDs: gameIDs, diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -378,7 +378,8 @@ enum RecordSerializer { static func applyMovesRecord( _ record: CKRecord, value: MovesValue, - to ctx: NSManagedObjectContext + to ctx: NSManagedObjectContext, + localAuthorID: String? = nil ) { let ckName = record.recordID.recordName let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") @@ -386,8 +387,10 @@ enum RecordSerializer { req.fetchLimit = 1 let entity: MovesEntity + let foundExisting: Bool if let existing = try? ctx.fetch(req).first { entity = existing + foundExisting = true } else { let game = ensureGameEntity( forGameID: value.gameID, @@ -396,12 +399,21 @@ enum RecordSerializer { ) entity = MovesEntity(context: ctx) entity.game = game + foundExisting = false } + // Always adopt system fields so future saves target the server's + // current change tag. If this is our own per-device row and it already + // exists locally, the local value state is authoritative; tokenless + // push-driven direct fetches can re-deliver an older server copy while + // newer edits are still queued for upload. entity.ckRecordName = ckName entity.ckSystemFields = encodeSystemFields(of: record) entity.authorID = value.authorID entity.deviceID = value.deviceID + let isLocalDeviceRow = value.authorID == localAuthorID + && value.deviceID == localDeviceID + guard !foundExisting || !isLocalDeviceRow else { return } entity.updatedAt = value.updatedAt entity.cells = (record["cells"] as? Data) ?? Data() diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -91,6 +91,7 @@ actor SyncEngine { private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? + private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? private var tracer: (@MainActor @Sendable (String) -> Void)? func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { @@ -117,6 +118,10 @@ actor SyncEngine { onGameRemoved = cb } + func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) { + localAuthorIDProvider = cb + } + init(container: CKContainer, persistence: PersistenceController) { self.container = container self.persistence = persistence @@ -498,6 +503,7 @@ actor SyncEngine { guard !records.isEmpty || !deletions.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let localAuthorID = await currentLocalAuthorID() let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() @@ -509,7 +515,12 @@ actor SyncEngine { if let id = entity.id { affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { - RecordSerializer.applyMovesRecord(record, value: value, to: ctx) + RecordSerializer.applyMovesRecord( + record, + value: value, + to: ctx, + localAuthorID: localAuthorID + ) movesUpdated.insert(value.gameID) affected.insert(value.gameID) } @@ -751,6 +762,13 @@ actor SyncEngine { // MARK: - Private helpers + private func currentLocalAuthorID() async -> String? { + guard let localAuthorIDProvider else { return nil } + return await MainActor.run { + localAuthorIDProvider() + } + } + private struct ZoneInfo { let scope: Int16 let zoneID: CKRecordZone.ID @@ -1141,6 +1159,7 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let localAuthorID = await currentLocalAuthorID() let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() @@ -1153,7 +1172,12 @@ actor SyncEngine { if let id = entity.id { affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { - RecordSerializer.applyMovesRecord(record, value: value, to: ctx) + RecordSerializer.applyMovesRecord( + record, + value: value, + to: ctx, + localAuthorID: localAuthorID + ) movesUpdated.insert(value.gameID) affected.insert(value.gameID) } diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift @@ -126,6 +126,129 @@ struct MovesInboundTests { #expect(decoded == cells2) } + @Test("Inbound local-device record does not clobber existing local row") + func inboundLocalDeviceRecordDoesNotClobberExistingLocalRow() 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 localCells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "B", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 20), + authorID: "alice" + ), + ] + let local = MovesEntity(context: ctx) + local.game = game + local.ckRecordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: "alice", + deviceID: RecordSerializer.localDeviceID + ) + local.authorID = "alice" + local.deviceID = RecordSerializer.localDeviceID + local.updatedAt = Date(timeIntervalSince1970: 20) + local.cells = try MovesCodec.encode(localCells) + try ctx.save() + + let serverCells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 10), + authorID: "alice" + ), + ] + let (rec, value) = try record( + in: ctx, + authorID: "alice", + deviceID: RecordSerializer.localDeviceID, + cells: serverCells, + updatedAt: Date(timeIntervalSince1970: 10) + ) + + RecordSerializer.applyMovesRecord( + rec, + value: value, + to: ctx, + localAuthorID: "alice" + ) + + let row = try #require(fetchAll(ctx).first) + #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) + let decoded = try MovesCodec.decode(row.cells ?? Data()) + #expect(decoded == localCells) + #expect(row.ckSystemFields != nil) + } + + @Test("Inbound other-device record replaces cached row") + func inboundOtherDeviceRecordReplacesCachedRow() 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 cachedCells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "B", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 20), + 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 = Date(timeIntervalSince1970: 20) + cached.cells = try MovesCodec.encode(cachedCells) + try ctx.save() + + let serverCells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 10), + authorID: "bob" + ), + ] + let (rec, value) = try record( + in: ctx, + authorID: "bob", + deviceID: "phone", + cells: serverCells, + updatedAt: Date(timeIntervalSince1970: 10) + ) + + RecordSerializer.applyMovesRecord( + rec, + value: value, + to: ctx, + localAuthorID: "alice" + ) + + let row = try #require(fetchAll(ctx).first) + #expect(row.updatedAt == Date(timeIntervalSince1970: 10)) + let decoded = try MovesCodec.decode(row.cells ?? Data()) + #expect(decoded == serverCells) + } + @Test("Two devices for the same game produce two distinct rows") func twoDevicesYieldTwoRows() throws { let persistence = makeTestPersistence()