crossmate

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

commit 5743ab756d898dd5ffc7fb2a20c29be0d73327fd
parent 8295b0d525f4149f67bcaa0ab3688d192145d016
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 23:51:58 +0900

Guard archive promotion against deleting a game with no replacement

When an owner revokes access to a finished shared game, promoteRevoked
rebuilds a durable owned copy and then deletes the revoked original so
the Game List shows a single completed game rather than a dead
tombstone. The previous path discarded the result of Archive.materialize
and deleted the original unconditionally, so a promotion that produced
no replacement row still removed the game — losing it entirely.

Archive.materialize returns nil when the payload's puzzleSource is
empty, which happens when the cloud Archive's puzzleSource CKAsset fails
to download and decodes to an empty string. That read is typically
transient, and the full archive remains intact in the private zone, so
the correct response is to keep the revoked row and let a later pass
promote it rather than delete into nothing.

Now the delete only runs once materialize returns a row. The offline
branch is unaffected: its payload is built from the local snapshot,
whose puzzleSource is guaranteed non-empty, so it always materialises
and promotes as before.

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

Diffstat:
MCrossmate/Sync/GameArchiver.swift | 8+++++++-
1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -218,7 +218,13 @@ final class GameArchiver { let promoteCtx = persistence.container.newBackgroundContext() promoteCtx.performAndWait { - _ = Archive.materialize(payload, in: promoteCtx) + // Only retire the revoked original once its replacement exists. + // materialize returns nil when the payload's puzzleSource is empty — + // e.g. a cloud Archive whose puzzleSource CKAsset failed to download + // (typically transient). Deleting anyway would drop the finished game + // with nothing to replace it; leave the revoked row so a later pass + // can promote it from the intact cloud archive. + guard Archive.materialize(payload, in: promoteCtx) != nil else { return } let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1