commit 04bfdfdb295bf6aa66e4af362782b2d1f2b57b2f
parent 76a6790f7915798779e66a04c63c8a1efe195bc5
Author: Michael Camilleri <[email protected]>
Date: Fri, 8 May 2026 06:11:09 +0900
Apply private-DB zone deletions on receiving devices
Diagnostics from a two-device session showed the receiving device fetching the
zone deletions, logging 'private db changes [src=push]: 4 zone mods, 2 zone
deletions', and then doing nothing with them. handleFetchedDatabaseChanges
bailed out early for the private engine, so the matching GameEntity rows
lingered with no remote data and the deletion never propagated.
This commit replaces the early return with a split:
1. Private-DB zone deletions delete the local GameEntity (cascade rules handle
child Move, Snapshot, Player, and Cell rows) and a new onGameRemoved callback
lets GameStore clear currentEntity / currentMutator / currentGame if the open
puzzle is the one that just disappeared.
2. Shared-DB zone deletions keep their existing access-revoked path, since 'the
owner removed our access' warrants a different UI affordance than 'the user
deleted their own game on another device'.
This commit also promotes the remaining `try? ctx.save()` calls inside the
fetched- changes handlers to logged catches. CKSyncEngine advances its change
token whenever the delegate returns from a fetched-record-zone-changes event,
regardless of whether we persisted anything; a silent save failure there means
the records are gone from the engine's 'to deliver' set and won't come back
without a manual resetSyncState. Surfacing the failure is the only way to
notice that drift if it ever recurs.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
3 files changed, 93 insertions(+), 32 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -672,6 +672,19 @@ final class GameStore {
currentMutator?.isAccessRevoked = true
}
+ /// Called after the sync engine deletes a `GameEntity` in response to a
+ /// remote private-DB zone deletion (the user removed this game on another
+ /// device). The deletion itself has already been merged into the view
+ /// context; this method's only job is to drop the active references if
+ /// the open puzzle is the one that just disappeared, so the UI doesn't
+ /// dereference a deleted managed object.
+ func handleRemoteRemoval(gameID: UUID) {
+ guard currentEntity?.id == gameID else { return }
+ currentGame = nil
+ currentMutator = nil
+ currentEntity = nil
+ }
+
/// Flips the active game's mutator to shared after `ShareController`
/// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster,
/// starts publishing presence) without requiring the user to re-open.
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -179,6 +179,10 @@ final class AppServices {
store.markAccessRevoked(gameID: gameID)
}
+ await syncEngine.setOnGameRemoved { [store] gameID in
+ store.handleRemoteRemoval(gameID: gameID)
+ }
+
await syncEngine.setOnSnapshotsSaved { [snapshotService, syncEngine] names in
let prunedMoveNames = snapshotService.pruneMoves(
ckRecordNames: Set(names)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -90,6 +90,7 @@ actor SyncEngine {
private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
+ private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
private var onSnapshotsSaved: (@MainActor @Sendable ([String]) async -> Void)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
@@ -113,6 +114,10 @@ actor SyncEngine {
onGameAccessRevoked = cb
}
+ func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
+ onGameRemoved = cb
+ }
+
func setOnSnapshotsSaved(_ cb: @MainActor @Sendable @escaping ([String]) async -> Void) {
onSnapshotsSaved = cb
}
@@ -903,51 +908,74 @@ actor SyncEngine {
"\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
)
- guard !isPrivate else { return }
-
- // For the shared engine, create placeholder game entities for new
- // zones (populated fully when the Game record arrives), and mark
- // games access-revoked when the owner removes us from the share.
+ // Private-DB zone deletions reflect the user removing one of their own
+ // games on another device — hard-delete locally so the row stops
+ // hanging around forever. Shared-DB zone deletions reflect the owner
+ // removing this account from the share — mark access-revoked instead
+ // so the UI can surface "no longer have access" rather than silently
+ // vanishing the row mid-game. Modifications on the shared DB also
+ // create placeholder GameEntities for newly-joined shares.
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let revokedIDs: [UUID] = ctx.performAndWait {
- var ids: [UUID] = []
- for mod in event.modifications {
- let zoneID = mod.zoneID
- 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
- if (try? ctx.fetch(req).first) == nil {
- // Placeholder until the Game record arrives.
- let entity = GameEntity(context: ctx)
- let uuidString = String(zoneName.dropFirst("game-".count))
- entity.id = UUID(uuidString: uuidString)
- entity.ckRecordName = zoneName
- entity.ckZoneName = zoneName
- entity.ckZoneOwnerName = zoneID.ownerName
- entity.databaseScope = 1
- entity.title = "Joining\u{2026}"
- entity.puzzleSource = ""
- entity.createdAt = Date()
- entity.updatedAt = Date()
+ let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait {
+ var removed: [UUID] = []
+ var revoked: [UUID] = []
+ if !isPrivate {
+ for mod in event.modifications {
+ let zoneID = mod.zoneID
+ 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
+ if (try? ctx.fetch(req).first) == nil {
+ // Placeholder until the Game record arrives.
+ let entity = GameEntity(context: ctx)
+ let uuidString = String(zoneName.dropFirst("game-".count))
+ entity.id = UUID(uuidString: uuidString)
+ entity.ckRecordName = zoneName
+ entity.ckZoneName = zoneName
+ entity.ckZoneOwnerName = zoneID.ownerName
+ entity.databaseScope = 1
+ entity.title = "Joining\u{2026}"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ }
}
}
for deletion in event.deletions {
let zoneName = deletion.zoneID.zoneName
+ guard zoneName.hasPrefix("game-") else { continue }
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
req.fetchLimit = 1
- if let entity = try? ctx.fetch(req).first {
+ 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 { ids.append(id) }
+ if let id = entity.id { revoked.append(id) }
+ }
+ }
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ let nsError = error as NSError
+ print(
+ "SyncEngine: db-changes ctx.save failed — domain=\(nsError.domain) " +
+ "code=\(nsError.code) \(nsError.localizedDescription)"
+ )
}
}
- if ctx.hasChanges { try? ctx.save() }
- return ids
+ return (removed, revoked)
}
+ for id in removedIDs {
+ if let cb = onGameRemoved { await cb(id) }
+ }
for id in revokedIDs {
if let cb = onGameAccessRevoked { await cb(id) }
}
@@ -1018,7 +1046,23 @@ actor SyncEngine {
for gameID in Set(moves.map { $0.gameID }) {
self.replayCellCache(for: gameID, in: ctx)
}
- if ctx.hasChanges { try? ctx.save() }
+ // CKSyncEngine advances its change token whenever the delegate
+ // returns from fetchedRecordZoneChanges, regardless of whether we
+ // persisted anything. A silent failure here means the records are
+ // gone from the engine's "to deliver" set — they won't come back
+ // without a `resetSyncState`. Surface failures so we can act.
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ let nsError = error as NSError
+ print(
+ "SyncEngine: fetchedRecordZoneChanges ctx.save failed " +
+ "— domain=\(nsError.domain) code=\(nsError.code) " +
+ "\(nsError.localizedDescription)"
+ )
+ }
+ }
return (moves, affected, pings)
}