commit 4e5f802ae14f7e4908b05da4948bcb8d5bb56a55
parent 2c1d092d6407809ef26969b58ae390124bae475d
Author: Michael Camilleri <[email protected]>
Date: Fri, 21 Aug 2026 07:56:54 +0900
Hand an owned puzzle over to its Chronicle when its zone retires
A finished puzzle the account owned still came back as the sticky,
input-blocking 'Puzzle Removed' banner when the user returned to it
after a sibling device retired its zone. The handover added for a
revoked share never covered this: a shared zone that disappears marks
the row access-revoked and promotes it to the Chronicle, while the
account's own private zone disappearing hard-deletes the row and posts
the removal banner, with nothing put in the puzzle's place.
This commit gives the removal path the same handover. restoreRetired
returns the id of the Chronicle it materialises, and the removal
callback substitutes that Chronicle at the top of the navigation stack
whenever the retired puzzle is on screen. The banner is left for a
removal with no Chronicle behind it — a solo puzzle deleted on another
device, a shared game left elsewhere, or a retirement whose Chronicle
could not be read. Unlike promoteRevoked the handover does not insist
on a complete replay, since that guard exists to spare a participant a
half-filled grid frozen while a revoked row is still there to open,
and here the live row is already gone.
The device that decides the retirement carried the same gap.
promoteOwnedBeforeRetirement deleted the live row outright, stranding a
mounted PuzzleView on a dead GameEntity; it now marks
isSupersededByChronicle and hands the puzzle over, leaving the deletion
to retireSupersededLiveRow once no view can still hold it. That
retirement and the Game List sweep, now sweepSupersededLiveGames, cover
an owned row as well as a revoked one, and puzzleDisappeared asks from
persisted state rather than the closing view's mutator, which an
owner-side retirement never touches. Private zone orphaning skips a row
already superseded, so the echo of this device's own zone delete does
not report a hard deletion for a substitution the user has already been
shown.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
5 files changed, 210 insertions(+), 54 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -1097,10 +1097,7 @@ private struct PuzzleDisplayView: View {
.onDisappear {
openPuzzleFollowUpTask?.cancel()
openPuzzleFollowUpTask = nil
- services.puzzleDisappeared(
- gameID: gameID,
- wasAccessRevoked: session?.mutator.isAccessRevoked == true
- )
+ services.puzzleDisappeared(gameID: gameID)
guard session?.mutator.isArchived == false else { return }
let selectionPublisher = services.playerSelectionPublisher
let movesUpdater = services.movesUpdater
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1278,7 +1278,7 @@ final class AppServices {
with: chronicleID
)
} else {
- await gameArchiver.retireRevokedLiveRow(gameID: gameID)
+ await gameArchiver.retireSupersededLiveRow(gameID: gameID)
}
}
// Surface the revocation as a sticky, input-blocking banner on
@@ -1303,17 +1303,31 @@ final class AppServices {
// Its compact private Archive is account-wide, but the Archive
// record was intentionally inert while the live row existed; apply
// it now that the zone deletion removed that row.
- await gameArchiver.restoreRetired(gameID: gameID)
+ let chronicleID = await gameArchiver.restoreRetired(gameID: gameID)
gameViewedStore.advance(Date(), forGame: gameID)
// The local row is gone, so drop its badge ledger entry: a seen
// horizon can't clear it once there's no game left to open.
BadgeState.forget(gameID: gameID)
await self?.badge.refreshAppBadge(reason: "game removed")
- // A hard-deleted game (private zone gone, or a shared game left
- // elsewhere) only needs UI when its puzzle is on screen: a sticky,
- // input-blocking banner freezes the now-orphaned puzzle until the
- // user backs out. Off-screen removals just drop from the list.
- if wasOpen {
+ // A retirement is not a deletion the user needs told about: a
+ // sibling device retires the zone once the game is chronicled, so an
+ // open puzzle is handed over to the Chronicle that replaced it —
+ // the same substitution `onGameAccessRevoked` makes for a
+ // participant whose shared zone went.
+ //
+ // The banner is what remains for a genuine hard delete (a solo
+ // puzzle deleted on another device, a shared game left elsewhere) or
+ // for a retirement whose Chronicle could not be read: sticky and
+ // input-blocking, it freezes the now-orphaned puzzle on screen until
+ // the user backs out. Off-screen removals just drop from the list.
+ if let chronicleID {
+ if self?.isPuzzleOnScreen(gameID: gameID) == true {
+ NotificationNavigationBroker.shared.replaceOpenGame(
+ gameID,
+ with: chronicleID
+ )
+ }
+ } else if wasOpen {
announcements.post(.gameRemoved(gameID: gameID))
}
await self?.accountPush.reconcilePushRegistration()
@@ -1536,9 +1550,9 @@ final class AppServices {
// screen, before the list renders, so the row goes straight from live
// game to Chronicle without a revoked state in between. Also the
// cold-launch backstop for a revocation that arrived while terminated.
- let promoted = await gameArchiver.promoteRevokedCompleted()
- for gameID in promoted where !isPuzzleOnScreen(gameID: gameID) {
- await gameArchiver.retireRevokedLiveRow(gameID: gameID)
+ let retirable = await gameArchiver.sweepSupersededLiveGames()
+ for gameID in retirable where !isPuzzleOnScreen(gameID: gameID) {
+ await gameArchiver.retireSupersededLiveRow(gameID: gameID)
}
await freshenGameList(reason: .appeared)
}
@@ -1554,15 +1568,22 @@ final class AppServices {
/// Clears the mounted destination before starting Chronicle cleanup. If
/// promotion is still fetching, the archiver joins that work before
/// deciding whether a complete Chronicle exists.
- func puzzleDisappeared(gameID: UUID, wasAccessRevoked: Bool) {
+ ///
+ /// The retirement attempt is driven by persisted state rather than by what
+ /// the closing puzzle believed about itself: a revocation flips the mutator
+ /// the open view holds, but an owner-side retirement replaces the row from
+ /// underneath without the view ever hearing about it. The archiver deletes
+ /// nothing that a Chronicle has not already replaced, so asking on every
+ /// close is safe.
+ func puzzleDisappeared(gameID: UUID) {
let remaining = max(0, mountedPuzzleCounts[gameID, default: 0] - 1)
if remaining == 0 {
mountedPuzzleCounts[gameID] = nil
} else {
mountedPuzzleCounts[gameID] = remaining
}
- guard wasAccessRevoked, remaining == 0 else { return }
- Task { await gameArchiver.retireRevokedLiveRow(gameID: gameID) }
+ guard remaining == 0 else { return }
+ Task { await gameArchiver.retireSupersededLiveRow(gameID: gameID) }
}
func loadRecentCompleted(since cutoff: Date) async -> GameArchiver.CompletedPage {
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -182,7 +182,8 @@ final class GameArchiver {
let ids: [UUID] = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
- format: "completedAt != nil AND isAccessRevoked == NO AND ckRecordName BEGINSWITH %@",
+ format: "completedAt != nil AND isAccessRevoked == NO " +
+ "AND isSupersededByChronicle == NO AND ckRecordName BEGINSWITH %@",
"game-"
)
return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
@@ -243,6 +244,11 @@ final class GameArchiver {
guard let entity = try? ctx.fetch(req).first,
entity.completedAt != nil,
!entity.isAccessRevoked,
+ // A row its Chronicle already stands for has finished with
+ // this path: the archive is written, the zone retirement is
+ // enqueued, and all that remains is deleting the row once no
+ // `PuzzleView` still holds it.
+ !entity.isSupersededByChronicle,
entity.ckRecordName?.hasPrefix("game-") == true,
let snapshot = Archive.snapshot(forGameID: gameID, in: ctx)
else { return nil }
@@ -383,34 +389,54 @@ final class GameArchiver {
)
guard await write(snapshot, replayState: .unavailable) else { return }
}
- guard await promoteOwnedBeforeRetirement(
+ guard let chronicleID = await promoteOwnedBeforeRetirement(
snapshot,
replayState: keepReplay ? .available : .unavailable
) else {
return
}
+ // The puzzle may be open on this device — retirement is decided by a
+ // cold-launch reconciliation pass that has no idea what is on screen.
+ // Hand it over rather than letting the row vanish underneath: the
+ // Chronicle is the same puzzle, so it takes the live game's place at
+ // the top of the stack. A no-op when no stack is showing it.
+ NotificationNavigationBroker.shared.replaceOpenGame(
+ snapshot.originalGameID,
+ with: chronicleID
+ )
await syncEngine.enqueueRetireOwnedGameZone(local.liveZoneID)
}
+ /// Materializes the Chronicle that replaces an owned game about to have its
+ /// zone retired, returning the Chronicle's game id.
+ ///
+ /// The live row is taken out of the library but *not* deleted: deleting it
+ /// under a mounted `PuzzleView` would strand that view on a dead
+ /// `GameEntity`. `retireSupersededLiveRow` deletes it once no view can
+ /// still hold it, exactly as the participant-side handover does.
private func promoteOwnedBeforeRetirement(
_ snapshot: Archive.Snapshot,
replayState: Archive.ReplayState
- ) async -> Bool {
+ ) async -> UUID? {
let ctx = persistence.container.newBackgroundContext()
return await ctx.perform {
let payload = Archive.payload(from: snapshot, replayState: replayState)
- guard Archive.materialize(payload, in: ctx) != nil else { return false }
+ guard let chronicle = Archive.materialize(payload, in: ctx),
+ let chronicleID = chronicle.id
+ else { return nil }
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
format: "id == %@", snapshot.originalGameID as CVarArg
)
req.fetchLimit = 1
- if let original = try? ctx.fetch(req).first { ctx.delete(original) }
+ if let original = try? ctx.fetch(req).first {
+ original.isSupersededByChronicle = true
+ }
do {
if ctx.hasChanges { try ctx.save() }
- return true
+ return chronicleID
} catch {
- return false
+ return nil
}
}
}
@@ -453,38 +479,61 @@ final class GameArchiver {
// MARK: - Restore / legacy migration
/// Rebuilds a completed game after another owner device retired its live
- /// zone and the sync applier removed the local live row first.
- func restoreRetired(gameID: UUID) async {
- guard let stored = await fetchArchive(originalGameID: gameID) else { return }
+ /// zone and the sync applier removed the local live row first, returning the
+ /// Chronicle's game id so an open puzzle can be handed over to it.
+ ///
+ /// Unlike `promoteRevoked` this does not insist on a complete replay. That
+ /// guard exists to keep a half-filled grid from being frozen while the user
+ /// still has a revoked row to look at; here the live row is already gone, so
+ /// whatever the account chronicled is the only representation left.
+ @discardableResult
+ func restoreRetired(gameID: UUID) async -> UUID? {
+ guard let stored = await fetchArchive(originalGameID: gameID) else { return nil }
let ctx = persistence.container.newBackgroundContext()
- await ctx.perform {
- _ = Archive.materialize(stored.payload, in: ctx)
- if ctx.hasChanges { try? ctx.save() }
+ return await ctx.perform {
+ guard let chronicle = Archive.materialize(stored.payload, in: ctx),
+ let chronicleID = chronicle.id
+ else { return nil }
+ do {
+ if ctx.hasChanges { try ctx.save() }
+ return chronicleID
+ } catch {
+ return nil
+ }
}
}
- /// Sweeps every finished game whose shared zone has gone, promoting each to
- /// its Chronicle and returning the live rows now eligible for retirement.
- /// Catches up both a revocation the app never got to act on because it was
- /// terminated and one whose Chronicle could not be read at the time.
- func promoteRevokedCompleted() async -> [UUID] {
+ /// Sweeps every finished game whose live zone has gone, promoting a newly
+ /// revoked one to its Chronicle, and returns every live row a Chronicle now
+ /// stands for — the ones just promoted and any earlier handover still
+ /// waiting on the puzzle that held it. Catches up a revocation the app never
+ /// got to act on because it was terminated, one whose Chronicle could not be
+ /// read at the time, and an owner-side retirement whose row outlived the
+ /// puzzle it was handed over from.
+ func sweepSupersededLiveGames() async -> [UUID] {
let ctx = persistence.container.newBackgroundContext()
- let ids: [UUID] = await ctx.perform {
+ let (revoked, superseded): ([UUID], [UUID]) = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
- format: "completedAt != nil AND isAccessRevoked == YES " +
- "AND ckRecordName BEGINSWITH %@",
+ format: "completedAt != nil AND ckRecordName BEGINSWITH %@ " +
+ "AND (isAccessRevoked == YES OR isSupersededByChronicle == YES)",
"game-"
)
- return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
+ let rows = (try? ctx.fetch(req)) ?? []
+ return (
+ rows.filter { !$0.isSupersededByChronicle }.compactMap(\.id),
+ rows.filter(\.isSupersededByChronicle).compactMap(\.id)
+ )
}
- var promoted: [UUID] = []
- for id in ids {
+ // Already-superseded rows skip promotion: they have their Chronicle,
+ // and re-materializing it would cost a CloudKit read per list opening.
+ var retirable = superseded
+ for id in revoked {
if await promoteRevoked(gameID: id) != nil {
- promoted.append(id)
+ retirable.append(id)
}
}
- return promoted
+ return retirable
}
/// Promotes a participant's private archive when the owner retires the live
@@ -570,20 +619,22 @@ final class GameArchiver {
}
}
- /// Deletes a revoked live row after its Chronicle is durable and any
- /// in-flight promotion has finished. Calling this from `PuzzleView`'s
- /// disappearance is what makes deletion safe for an open-puzzle handover;
- /// off-screen callers can invoke it immediately after promotion.
+ /// Deletes a live row its Chronicle has replaced — whether by a participant
+ /// revocation or an owner-side zone retirement — once that Chronicle is
+ /// durable and any in-flight promotion has finished. Calling this from
+ /// `PuzzleView`'s disappearance is what makes deletion safe for an
+ /// open-puzzle handover; off-screen callers can invoke it immediately after
+ /// promotion.
@discardableResult
- func retireRevokedLiveRow(gameID: UUID) async -> Bool {
+ func retireSupersededLiveRow(gameID: UUID) async -> Bool {
await revokedPromotionCoalescer.waitForCurrentTask(for: gameID)
return await revokedRetirementCoalescer.run(for: gameID) { [weak self] in
guard let self else { return false }
- return await self.performRetireRevokedLiveRow(gameID: gameID)
+ return await self.performRetireSupersededLiveRow(gameID: gameID)
}
}
- private func performRetireRevokedLiveRow(gameID: UUID) async -> Bool {
+ private func performRetireSupersededLiveRow(gameID: UUID) async -> Bool {
let ctx = persistence.container.newBackgroundContext()
return await ctx.perform {
let chronicleReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -596,8 +647,8 @@ final class GameArchiver {
let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
liveReq.predicate = NSPredicate(
- format: "id == %@ AND isAccessRevoked == YES " +
- "AND ckRecordName BEGINSWITH %@",
+ format: "id == %@ AND ckRecordName BEGINSWITH %@ " +
+ "AND (isAccessRevoked == YES OR isSupersededByChronicle == YES)",
gameID as CVarArg,
"game-"
)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -2524,6 +2524,13 @@ actor SyncEngine {
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { continue }
if isPrivate {
+ // A row already replaced by its Chronicle is mid-retirement:
+ // this device asked for the zone deletion, the open puzzle
+ // has been handed over, and the archiver deletes the row
+ // once no `PuzzleView` still holds it. Removing it here —
+ // this is usually the echo of our own delete — would report
+ // a hard deletion for a substitution already made.
+ guard !entity.isSupersededByChronicle else { continue }
if let id = entity.id { removed.append(id) }
ctx.delete(entity)
} else {
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -97,7 +97,7 @@ struct ArchiveTests {
)
try ctx.save()
- #expect(await archiver.retireRevokedLiveRow(gameID: original) == false)
+ #expect(await archiver.retireSupersededLiveRow(gameID: original) == false)
let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
liveRequest.predicate = NSPredicate(format: "id == %@", original as CVarArg)
#expect(try ctx.count(for: liveRequest) == 1)
@@ -131,7 +131,7 @@ struct ArchiveTests {
)
try ctx.save()
- #expect(await archiver.retireRevokedLiveRow(gameID: original))
+ #expect(await archiver.retireSupersededLiveRow(gameID: original))
let verifyCtx = persistence.container.newBackgroundContext()
let counts = await verifyCtx.perform {
let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -150,6 +150,86 @@ struct ArchiveTests {
#expect(counts.chronicle == 1)
}
+ @Test("retirement deletes an owned row handed over to its Chronicle")
+ func retirementDeletesOwnedHandedOverRow() async throws {
+ let persistence = makeTestPersistence()
+ let engine = try makeSyncEngine(persistence)
+ let archiver = GameArchiver(
+ container: CloudContainer.container,
+ persistence: persistence,
+ syncEngine: engine
+ )
+ let original = UUID()
+ let ctx = persistence.viewContext
+ let live = GameEntity(context: ctx)
+ live.id = original
+ live.title = "Live"
+ live.puzzleSource = source
+ live.createdAt = Date()
+ live.updatedAt = Date()
+ live.completedAt = Date()
+ // The owner's own game: never access-revoked, replaced by the Chronicle
+ // when this account retired the zone.
+ live.databaseScope = 0
+ live.isSupersededByChronicle = true
+ live.ckRecordName = "game-\(original.uuidString)"
+
+ _ = Archive.materialize(
+ Archive.payload(from: sampleSnapshot(originalGameID: original)),
+ in: ctx
+ )
+ try ctx.save()
+
+ #expect(await archiver.retireSupersededLiveRow(gameID: original))
+ let verifyCtx = persistence.container.newBackgroundContext()
+ let remaining = await verifyCtx.perform {
+ let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ liveRequest.predicate = NSPredicate(format: "id == %@", original as CVarArg)
+ return (try? verifyCtx.count(for: liveRequest)) ?? -1
+ }
+ #expect(remaining == 0)
+ }
+
+ @Test("the sweep collects handed-over rows and leaves ordinary ones alone")
+ func sweepCollectsHandedOverRows() async throws {
+ let persistence = makeTestPersistence()
+ let engine = try makeSyncEngine(persistence)
+ let archiver = GameArchiver(
+ container: CloudContainer.container,
+ persistence: persistence,
+ syncEngine: engine
+ )
+ let ctx = persistence.viewContext
+
+ let handedOver = UUID()
+ let superseded = GameEntity(context: ctx)
+ superseded.id = handedOver
+ superseded.title = "Handed over"
+ superseded.puzzleSource = source
+ superseded.createdAt = Date()
+ superseded.updatedAt = Date()
+ superseded.completedAt = Date()
+ superseded.isSupersededByChronicle = true
+ superseded.ckRecordName = "game-\(handedOver.uuidString)"
+
+ let ordinary = UUID()
+ let finished = GameEntity(context: ctx)
+ finished.id = ordinary
+ finished.title = "Finished"
+ finished.puzzleSource = source
+ finished.createdAt = Date()
+ finished.updatedAt = Date()
+ finished.completedAt = Date()
+ finished.ckRecordName = "game-\(ordinary.uuidString)"
+ try ctx.save()
+
+ // The handed-over row needs no promotion — asking for one would cost a
+ // CloudKit read per list opening — and a finished game nothing has
+ // replaced is not the sweep's business at all.
+ let retirable = await archiver.sweepSupersededLiveGames()
+ #expect(retirable == [handedOver])
+ }
+
@Test("recent Chronicle paging advances through the returned cursor")
func recentPagingAdvancesCursor() async throws {
let now = Date()