commit ee646a58690d30804aee98782ec632e1f85e721c
parent 17d2e12bd21ff0e42a98db527e0cceb33aa8befc
Author: Michael Camilleri <[email protected]>
Date: Tue, 14 Apr 2026 22:36:29 +0900
Ensure zone ID is known before pushes and fetches
Diffstat:
1 file changed, 31 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -10,7 +10,14 @@ actor SyncEngine {
let privateDatabase: CKDatabase
let persistence: PersistenceController
- private let zoneID: CKRecordZone.ID
+ /// Starts life with `CKCurrentUserDefaultName` as the owner placeholder
+ /// and is rebuilt with the user's real owner record ID the first time
+ /// `resolveOwnerIfNeeded()` runs. Using the real owner up front would
+ /// block init on a network round-trip, so we resolve lazily instead.
+ /// Once resolved, equality checks against zone IDs returned by the
+ /// server (which always use the real owner) behave correctly.
+ private var zoneID: CKRecordZone.ID
+ private var ownerResolved: Bool = false
/// Called on the MainActor with decoded cell changes after remote records
/// have been applied to Core Data. Wired up in CrossmateApp to route
@@ -42,12 +49,28 @@ actor SyncEngine {
self.zoneID = RecordSerializer.zoneID()
}
+ // MARK: - Owner resolution
+
+ /// Replaces the placeholder owner in `zoneID` with the real user record
+ /// ID on first use. Must be called before any operation that compares
+ /// local zone IDs against server-returned ones (fetch database changes,
+ /// fetch zone changes, push) — otherwise equality checks silently fail
+ /// because the server always stamps zones with the resolved owner.
+ private func resolveOwnerIfNeeded() async throws {
+ guard !ownerResolved else { return }
+ let userRecordID = try await container.userRecordID()
+ zoneID = CKRecordZone.ID(zoneName: zoneID.zoneName, ownerName: userRecordID.recordName)
+ ownerResolved = true
+ }
+
// MARK: - Bootstrap
/// Ensures the custom zone exists in the private database. Idempotent —
/// safe to call on every launch. Skips the network call if the zone has
/// already been created (tracked via `SyncStateEntity`).
func bootstrap() async throws {
+ try await resolveOwnerIfNeeded()
+
let context = persistence.container.newBackgroundContext()
let alreadyCreated: Bool = context.performAndWait {
SyncStateEntity.current(in: context).zoneCreated
@@ -126,6 +149,8 @@ actor SyncEngine {
/// Loops until the outbox is empty, processing up to 400 records per batch
/// (the CloudKit per-operation limit).
func pushChanges() async throws {
+ try await resolveOwnerIfNeeded()
+
let context = persistence.container.newBackgroundContext()
var serverWinsCellChanges: [RemoteCellChange] = []
var iteration = 0
@@ -311,6 +336,8 @@ actor SyncEngine {
/// Applies incoming records to Core Data, persists change tokens, then
/// hops to MainActor to refresh the in-memory Game.
func fetchChanges() async throws {
+ try await resolveOwnerIfNeeded()
+
let context = persistence.container.newBackgroundContext()
// Step 1: Fetch database-level changes to discover changed zones
@@ -320,7 +347,9 @@ actor SyncEngine {
let changedZoneIDs = try await fetchDatabaseChanges(token: databaseToken, context: context)
- // Only proceed if our zone has changes
+ // Only proceed if our zone has changes. Equality works here because
+ // `resolveOwnerIfNeeded()` has rebuilt `zoneID` with the real owner
+ // record ID, matching what the server returns.
guard changedZoneIDs.contains(zoneID) else { return }
// Step 2: Fetch zone-level changes