crossmate

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

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:
MCrossmate/Sync/SyncEngine.swift | 33+++++++++++++++++++++++++++++++--
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