crossmate

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

commit c32b4f1b00aee2a86fcf00abd2419f51d1663b29
parent 763050477d91745f8695c0d4ec92e131d6cd2aaf
Author: Michael Camilleri <[email protected]>
Date:   Tue, 12 May 2026 18:10:33 +0900

Pull updates for known game zones on library refresh

The pull-to-refresh action added in the previous commit only covered half of
'what might have changed elsewhere': new zones the device had never seen.
Updates to games already in the library — new moves, player roster changes —
still relied on CKSyncEngine's eventual fetch, which is the very thing the
manual refresh is meant to compensate for. The effect was that pulling on a
device with stale state did nothing visible until the engine caught up on its
own schedule.

This commit adds SyncEngine.fetchKnownZoneUpdatesDirect(scope:), which
iterates the locally-known games for the scope (via a new
knownGameIDs(forScope:in:) helper that mirrors knownZones) and routes
each one through the existing fetchPushChangesDirect. That path fetches
the Game record by ID and queries Moves and Player incrementally
against liveQueryCheckpoints, so successive refreshes only pull records
modified since the previous run. Per-game errors are caught and traced
so one failing zone doesn't abort the rest of the batch.

AppServices.refreshLibrary now runs the four direct phases (discovery and
known-zone updates, on both database scopes) before falling through to the
engine fetch.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 6++++++
MCrossmate/Sync/SyncEngine.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -272,6 +272,12 @@ final class AppServices { await syncMonitor.run("library refresh: shared discovery") { _ = try await syncEngine.discoverNewZonesDirect(scope: .shared) } + await syncMonitor.run("library refresh: private known-zone updates") { + _ = try await syncEngine.fetchKnownZoneUpdatesDirect(scope: .private) + } + await syncMonitor.run("library refresh: shared known-zone updates") { + _ = try await syncEngine.fetchKnownZoneUpdatesDirect(scope: .shared) + } await syncMonitor.run("library refresh: engine fetch") { try await syncEngine.fetchChanges(source: "library refresh") } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -715,6 +715,61 @@ actor SyncEngine { return zonesWithGame } + /// Pulls incremental updates for every game the device already knows + /// about in the given scope, bypassing CKSyncEngine. Pairs with + /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves + /// of "what might have changed elsewhere": new zones *and* updates to + /// existing ones. Each game is dispatched to the existing + /// `fetchPushChangesDirect`, which uses the `liveQueryCheckpoints` + /// cursor so we only pull Moves/Player records newer than the last + /// direct fetch. Per-game errors are caught and traced so one bad zone + /// doesn't abort the rest. + /// + /// Returns the number of games for which the direct fetch reported + /// records were applied. + @discardableResult + func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int { + let scopeValue: Int16 + let label: String + switch scope { + case .private: + scopeValue = 0 + label = "private" + case .shared: + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let ctx = persistence.container.newBackgroundContext() + let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx) + guard !gameIDs.isEmpty else { + await trace("\(label) known-zone refresh: no known games") + return 0 + } + + var handled = 0 + for gameID in gameIDs { + do { + if try await fetchPushChangesDirect(scope: scope, gameID: gameID) { + handled += 1 + } + } catch { + await trace( + "\(label) known-zone refresh: game \(gameID.uuidString.prefix(8)) " + + "failed: \(error.localizedDescription)" + ) + } + } + await trace( + "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)" + ) + return handled + } + private func queryLiveRecords( type: CKRecord.RecordType, database: CKDatabase, @@ -1070,6 +1125,22 @@ actor SyncEngine { } } + /// Game UUIDs for every locally-known game in the given database scope. + /// Used by the known-zone refresh path so each game can be routed + /// through `fetchPushChangesDirect`, which is the existing per-game + /// direct fetch. + private nonisolated func knownGameIDs( + forScope scope: Int16, + in ctx: NSManagedObjectContext + ) -> [UUID] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "databaseScope == %d", scope) + guard let entities = try? ctx.fetch(req) else { return [] } + return entities.compactMap(\.id) + } + } + /// Enumerates every known game zone for the given database scope, paired /// with the `createdAt` of the corresponding GameEntity. The createdAt /// timestamp is used as the per-zone floor for the ping fast path: pings