commit 9b285ad724c8f424dc46040ab063d4faf7e7aa21
parent 75b0b776f45cc97eef42c9154743feb771211edc
Author: Michael Camilleri <[email protected]>
Date: Sun, 10 May 2026 15:18:48 +0900
Limit push fallback to active game
The silent-push fallback only needs live collaboration state for the currently
open puzzle. This commit replaces the broad nil-token replay with an
active-zone query that fetches the Game record and recently modified Moves and
Player records, while continuing to leave Ping records out of the live path.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 139 insertions(+), 89 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -287,12 +287,41 @@ final class AppServices {
await refreshSnapshot()
return
}
+ guard let activeGameID = activeGameID(in: scope) else {
+ await syncMonitor.run("remote-notification fetch") {
+ try await syncEngine.fetchChanges(source: "push")
+ }
+ await refreshSnapshot()
+ return
+ }
await syncMonitor.run("remote-notification direct fetch") {
- try await syncEngine.fetchPushChangesDirect(scope: scope)
+ let handled = try await syncEngine.fetchPushChangesDirect(
+ scope: scope,
+ gameID: activeGameID
+ )
+ if !handled {
+ try await syncEngine.fetchChanges(source: "push")
+ }
}
await refreshSnapshot()
}
+ private func activeGameID(in scope: CKDatabase.Scope) -> UUID? {
+ guard let entity = store.currentEntity,
+ let gameID = entity.id
+ else { return nil }
+ switch scope {
+ case .private:
+ return entity.databaseScope == 0 ? gameID : nil
+ case .shared:
+ return entity.databaseScope == 1 ? gameID : nil
+ case .public:
+ return nil
+ @unknown default:
+ return nil
+ }
+ }
+
private func ensureICloudSyncStarted() async -> Bool {
guard preferences.isICloudSyncEnabled else { return false }
guard !syncStarted else { return true }
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -93,6 +93,8 @@ actor SyncEngine {
private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
+ private var liveQueryCheckpoints: [String: Date] = [:]
+ private let liveQueryCheckpointOverlap: TimeInterval = 5
func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
tracer = t
@@ -381,17 +383,17 @@ actor SyncEngine {
_ = try await (p, s)
}
- /// Push-only fallback path. On device, CKSyncEngine.fetchChanges() can
- /// return successfully from a silent-push wake without delivering database
- /// or record-zone events until a later foreground fetch. This direct pull
- /// trades token efficiency for latency: it asks CloudKit for modified
- /// zones in the notified database scope from a nil token and applies the
- /// returned Game/Moves/Player records through the same idempotent merge path
- /// used by CKSyncEngine events. Event-like records such as Ping are
- /// intentionally ignored here because a nil-token fetch can redeliver old
- /// records. CKSyncEngine remains the owner of durable tokens and will
- /// catch up during normal fetches.
- func fetchPushChangesDirect(scope: CKDatabase.Scope) async throws {
+ /// Push-only fallback path for the currently open game. On device,
+ /// CKSyncEngine.fetchChanges() can return successfully from a silent-push
+ /// wake without delivering database or record-zone events until a later
+ /// foreground fetch. This direct pull is deliberately narrow: it refreshes
+ /// the active game's Game record and recently-modified Moves/Player
+ /// records in that game's zone, then applies them through the same
+ /// idempotent merge path used by CKSyncEngine events. Event-like records
+ /// such as Ping are intentionally ignored because live play only needs the
+ /// current collaboration state.
+ @discardableResult
+ func fetchPushChangesDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool {
let database: CKDatabase
let scopeValue: Int16
let label: String
@@ -405,96 +407,115 @@ actor SyncEngine {
scopeValue = 1
label = "shared"
case .public:
- return
+ return false
@unknown default:
- return
+ return false
}
- var changedZoneIDs: [CKRecordZone.ID] = []
- var databaseToken: CKServerChangeToken?
- repeat {
- let result = try await database.databaseChanges(since: databaseToken)
- changedZoneIDs.append(contentsOf: result.modifications.map(\.zoneID))
- databaseToken = result.moreComing ? result.changeToken : nil
- if !result.deletions.isEmpty {
- await applyDirectZoneDeletions(
- result.deletions.map(\.zoneID),
- scopeValue: scopeValue
- )
- }
- } while databaseToken != nil
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx),
+ info.scope == scopeValue
+ else {
+ await trace("\(label) live query skipped: no active game in scope")
+ return false
+ }
- let gameZoneIDs = Array(Set(changedZoneIDs))
- .filter { $0.zoneName.hasPrefix("game-") }
- await trace(
- "\(label) direct push db changes: \(gameZoneIDs.count) game zone(s)"
+ let checkpointKey = "\(scopeValue):\(gameID.uuidString)"
+ let since = liveQueryCheckpoints[checkpointKey]?
+ .addingTimeInterval(-liveQueryCheckpointOverlap)
+
+ let gameRecordID = CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: info.zoneID
)
+ var records: [CKRecord] = []
+ let gameResults = try await database.records(
+ for: [gameRecordID],
+ desiredKeys: ["title", "completedAt", "shareRecordName"]
+ )
+ let fetchedGameRecord: Bool
+ if case .success(let record)? = gameResults[gameRecordID] {
+ records.append(record)
+ fetchedGameRecord = true
+ } else {
+ fetchedGameRecord = false
+ }
- var totalModifications = 0
- var totalDeletions = 0
- for zoneID in gameZoneIDs {
- var zoneToken: CKServerChangeToken?
- repeat {
- let result = try await database.recordZoneChanges(
- inZoneWith: zoneID,
- since: zoneToken
- )
- let records = result.modificationResultsByID.compactMap { _, recordResult in
- try? recordResult.get().record
- }
- let deletions = result.deletions.map { ($0.recordID, $0.recordType) }
- totalModifications += records.count
- totalDeletions += deletions.count
- await applyDirectRecordZoneChanges(
- records: records,
- deletions: deletions,
- scopeValue: scopeValue
- )
- zoneToken = result.moreComing ? result.changeToken : nil
- } while zoneToken != nil
+ let moves = try await queryLiveRecords(
+ type: "Moves",
+ database: database,
+ zoneID: info.zoneID,
+ since: since,
+ desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
+ )
+ let players = try await queryLiveRecords(
+ type: "Player",
+ database: database,
+ zoneID: info.zoneID,
+ since: since,
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
+ )
+ records.append(contentsOf: moves)
+ records.append(contentsOf: players)
+
+ await applyDirectRecordZoneChanges(
+ records: records,
+ deletions: [],
+ scopeValue: scopeValue
+ )
+
+ let latestModification = records.compactMap(\.modificationDate).max()
+ if let latestModification {
+ liveQueryCheckpoints[checkpointKey] = latestModification
}
+
await trace(
- "\(label) direct push fetch: \(totalModifications) modifications, \(totalDeletions) deletions"
+ "\(label) live query fetch \(gameID.uuidString.prefix(8)): " +
+ "game=\(fetchedGameRecord ? 1 : 0), " +
+ "moves=\(moves.count), players=\(players.count)"
)
+ return true
}
- private func applyDirectZoneDeletions(
- _ zoneIDs: [CKRecordZone.ID],
- scopeValue: Int16
- ) async {
- guard !zoneIDs.isEmpty else { return }
- let isPrivate = scopeValue == 0
- let ctx = persistence.container.newBackgroundContext()
- ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait {
- var removed: [UUID] = []
- var revoked: [UUID] = []
- for zoneID in zoneIDs {
- let zoneName = zoneID.zoneName
- guard zoneName.hasPrefix("game-") else { continue }
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
- req.fetchLimit = 1
- guard let entity = try? ctx.fetch(req).first else { continue }
- if isPrivate {
- if let id = entity.id { removed.append(id) }
- ctx.delete(entity)
- } else {
- entity.isAccessRevoked = true
- if let id = entity.id { revoked.append(id) }
- }
- }
- if ctx.hasChanges {
- try? ctx.save()
- }
- return (removed, revoked)
- }
- for id in removedIDs {
- if let cb = onGameRemoved { await cb(id) }
+ private func queryLiveRecords(
+ type: CKRecord.RecordType,
+ database: CKDatabase,
+ zoneID: CKRecordZone.ID,
+ since: Date?,
+ desiredKeys: [CKRecord.FieldKey]
+ ) async throws -> [CKRecord] {
+ let predicate: NSPredicate
+ if let since {
+ predicate = NSPredicate(format: "modificationDate > %@", since as NSDate)
+ } else {
+ predicate = NSPredicate(value: true)
}
- for id in revokedIDs {
- if let cb = onGameAccessRevoked { await cb(id) }
+
+ let query = CKQuery(recordType: type, predicate: predicate)
+ query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: true)]
+
+ var records: [CKRecord] = []
+ var result = try await database.records(
+ matching: query,
+ inZoneWith: zoneID,
+ desiredKeys: desiredKeys,
+ resultsLimit: CKQueryOperation.maximumResults
+ )
+ records.append(contentsOf: result.matchResults.compactMap { _, recordResult in
+ try? recordResult.get()
+ })
+
+ while let cursor = result.queryCursor {
+ result = try await database.records(
+ continuingMatchFrom: cursor,
+ desiredKeys: desiredKeys,
+ resultsLimit: CKQueryOperation.maximumResults
+ )
+ records.append(contentsOf: result.matchResults.compactMap { _, recordResult in
+ try? recordResult.get()
+ })
}
+ return records
}
private func applyDirectRecordZoneChanges(