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:
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()