crossmate

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

commit c627e574ad0468e9b15eede6ae281929b28f1c60
parent 3f983cbf2c356a558ea13258d373a53e679f8963
Author: Michael Camilleri <[email protected]>
Date:   Fri,  5 Jun 2026 14:20:23 +0900

Treat pending private zones as freshen-ready

A newly-created owned game writes its zone name locally before CloudKit
has necessarily created the per-game zone. If the puzzle view appears in
that window, the direct grid freshen queries the zone and CloudKit
returns .zoneNotFound, which diagnostics surfaced as a sync error.

The active-grid direct fetch now downgrades .zoneNotFound only for
private games whose root Game record has never been CloudKit-confirmed.
That path logs the catch-up as skipped while the zone is pending
creation; established private zones and shared-zone losses still surface
normally.

Engine round-trip success also clears stale last-error fields, so a
later successful CKSyncEngine fetch or send does not leave diagnostics
pinned to an already-recovered failure.

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

Diffstat:
MCrossmate/Services/DebuggingMonitors.swift | 1+
MCrossmate/Sync/CloudQuery.swift | 60+++++++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Sync/CloudZones.swift | 4+++-
MTests/Unit/Sync/PerGameZoneTests.swift | 30+++++++++++++++++++++++++++++-
ATests/Unit/SyncMonitorTests.swift | 30++++++++++++++++++++++++++++++
5 files changed, 104 insertions(+), 21 deletions(-)

diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -261,6 +261,7 @@ final class SyncMonitor { /// auto-drain activity is happening. func noteSuccess() { lastSuccessAt = Date() + clearLastError() } func updateSnapshot(_ snapshot: SyncEngine.DiagnosticSnapshot) { diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -529,25 +529,41 @@ extension SyncEngine { zoneID: info.zoneID ) - async let gameResultsTask = database.records( - for: [gameRecordID], - desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"] - ) - async let movesTask = queryLiveRecords( - type: "Moves", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] - ) - async let playersTask = queryLiveRecords( - type: "Player", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "sessionSnapshot", "pushAddress"] - ) - let (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) + let gameResults: [CKRecord.ID: Result<CKRecord, Error>] + let moves: [CKRecord] + let players: [CKRecord] + do { + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"] + ) + async let movesTask = queryLiveRecords( + type: "Moves", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + async let playersTask = queryLiveRecords( + type: "Player", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "sessionSnapshot", "pushAddress"] + ) + (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) + } catch { + if scope == .private, + !info.isCloudConfirmed, + isZoneNotFoundError(error) { + await trace( + "\(label) game catch-up: \(gameID.uuidString.prefix(8)) " + + "skipped (zone pending creation)" + ) + return true + } + throw error + } var records = moves + players let gameCount: Int @@ -578,6 +594,12 @@ extension SyncEngine { return true } + nonisolated func isZoneNotFoundError(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == CKErrorDomain && + nsError.code == CKError.zoneNotFound.rawValue + } + /// Background-push catch-up for library freshness. Intentionally skips /// Player records because the immediate background session scan already /// covers presence. diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -7,6 +7,7 @@ extension SyncEngine { let scope: Int16 let zoneID: CKRecordZone.ID let isAccessRevoked: Bool + let isCloudConfirmed: Bool } struct ActivityZoneInfo: Sendable { @@ -32,7 +33,8 @@ extension SyncEngine { return ZoneInfo( scope: entity.databaseScope, zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - isAccessRevoked: entity.isAccessRevoked + isAccessRevoked: entity.isAccessRevoked, + isCloudConfirmed: entity.ckSystemFields != nil ) } } diff --git a/Tests/Unit/Sync/PerGameZoneTests.swift b/Tests/Unit/Sync/PerGameZoneTests.swift @@ -28,11 +28,39 @@ struct PerGameZoneTests { #expect(zone.ownerName == "_someOwnerID") } -@Test("recordName(forGameID:) embedded in zoneID zoneName") + @Test("recordName(forGameID:) embedded in zoneID zoneName") func gameRecordNameMatchesZoneName() { let id = UUID() let zoneName = RecordSerializer.zoneID(for: id).zoneName let recordName = RecordSerializer.recordName(forGameID: id) #expect(zoneName == recordName) } + + @Test("zoneInfo reports unconfirmed local game zones") + @MainActor + func zoneInfoReportsCloudConfirmation() { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + let zoneName = "game-\(id.uuidString)" + let entity = GameEntity(context: ctx) + entity.id = id + entity.title = "Local" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = zoneName + entity.ckZoneName = zoneName + entity.databaseScope = 0 + try? ctx.save() + + let engine = SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence + ) + #expect(engine.zoneInfo(forGameID: id, in: ctx)?.isCloudConfirmed == false) + + entity.ckSystemFields = Data([0x01]) + try? ctx.save() + #expect(engine.zoneInfo(forGameID: id, in: ctx)?.isCloudConfirmed == true) + } } diff --git a/Tests/Unit/SyncMonitorTests.swift b/Tests/Unit/SyncMonitorTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Sync monitor") +@MainActor +struct SyncMonitorTests { + @Test("Engine checkpoint clears stale last error") + func engineCheckpointClearsLastError() { + let monitor = SyncMonitor(log: EventLog()) + monitor.recordError( + "freshen puzzle grid appeared", + NSError( + domain: "CKErrorDomain", + code: 26, + userInfo: [NSLocalizedDescriptionKey: "Zone does not exist"] + ) + ) + + #expect(monitor.lastErrorPhase == "freshen puzzle grid appeared") + monitor.noteSuccess() + + #expect(monitor.lastSuccessAt != nil) + #expect(monitor.lastErrorPhase == nil) + #expect(monitor.lastErrorDomain == nil) + #expect(monitor.lastErrorCode == nil) + #expect(monitor.lastErrorDescription == nil) + } +}