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:
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)
+ }
+}