crossmate

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

commit 435238d00501453337189463a9dab4beebdf30c4
parent d52619358ced96ddde4b942bc1f0808bc7476f28
Author: Michael Camilleri <[email protected]>
Date:   Sun, 21 Jun 2026 07:18:55 +0900

Discard a finished game's peer-change ledger

The peer-change ledger behind the 'changed while you were away' borders
and catch-up banner only has meaning while a game is live. A completed
game is terminal — its grid is sealed and those surfaces never read its
rows again — so the ledger it had accumulated was simply dead storage,
left to pile up across the library as games finished.

This commit drops a game's PeerChangeEntity rows once it completes and
stops maintaining them. The terminal-game path lives in
updatePeerChangeLedger itself: seeing completedAt set, it deletes the
rows and skips the rebuild, so even a late peer move arriving for a
finished game cleans up rather than re-seeds. Completion schedules that
pass from every route a game can finish on this device —
persistCompletion when this device makes or observes the winning move,
and onGameCompleted when the finish is learned purely from a synced Game
record via applyGameRecord. The cascade on the game's relationship
remains the backstop for when the game itself is deleted.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 24++++++++++++++++++++----
MCrossmate/Services/AppServices.swift | 4++++
MTests/Unit/Sync/SessionMonitorTests.swift | 23+++++++++++++++++++++++
3 files changed, 47 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -535,7 +535,8 @@ final class GameStore { /// (`PeerChangeEntity`) for each game that just received inbound moves. For /// every cell whose letter differs from what the ledger holds, upserts a row /// stamped with the move's letter-change time; a check (a mark-only - /// re-stamp) leaves the letter unchanged and so writes nothing. Runs on a + /// re-stamp) leaves the letter unchanged and so writes nothing. A completed + /// game is terminal, so its rows are dropped and not rebuilt. Runs on a /// background context, off the main actor. /// /// The ledger is what the "changed while you were away" borders and catch-up @@ -560,16 +561,28 @@ final class GameStore { gameReq.fetchLimit = 1 guard let game = try? ctx.fetch(gameReq).first else { continue } + let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") + ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + let existingRows = (try? ctx.fetch(ledgerReq)) ?? [] + + // A completed game is terminal: its grid is sealed and the + // "changed while you were away" surfaces never read this ledger + // again, so drop the rows and stop maintaining it. The cleanup + // lives here, not only at the completion call site, because a + // late peer move can still arrive for a finished game. + if game.completedAt != nil { + for row in existingRows { ctx.delete(row) } + continue + } + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") movesReq.predicate = NSPredicate(format: "game == %@", game) let values: [MovesValue] = ((try? ctx.fetch(movesReq)) ?? []) .compactMap { Self.movesValue(from: $0) } let current = GridStateMerger.mergeWithProvenance(values) - let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") - ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) var rowByPosition: [GridPosition: PeerChangeEntity] = [:] - for row in (try? ctx.fetch(ledgerReq)) ?? [] { + for row in existingRows { rowByPosition[GridPosition(row: Int(row.row), col: Int(row.col))] = row } let recorded = rowByPosition.mapValues { Self.peerChange(from: $0) } @@ -1022,6 +1035,9 @@ final class GameStore { entity.completedBy = authorID entity.hasPendingSave = true try context.save() + // The game is now terminal, so its peer-change ledger is dead weight. + // The writer drops the rows once it sees completedAt — schedule it. + enqueuePeerChangeLedgerUpdate(for: [id]) // Lock the open session immediately so no further input lands on the // now-terminal game (the view also reflects this via `isSolved`). if currentEntity?.id == id { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -822,6 +822,10 @@ final class AppServices { await syncEngine.setOnGameCompleted { [weak self] gameID in await self?.shareController.closeTicketForCompletedGame(gameID: gameID) + // Completion learned purely via sync (this device wasn't present at + // the finish, so persistCompletion never ran): drop the now-useless + // peer-change ledger, the writer's terminal-game path doing the work. + self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID]) } await syncEngine.setOnGameJoined { [weak self] gameID in diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -377,4 +377,27 @@ struct SessionMonitorTests { #expect(summary.added == 1) #expect(summary.cleared == 0) } + + @Test("Completing a game drops its peer-change ledger") + func completionClearsLedger() async throws { + let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", after), position(0, 1): ("B", after)] + ) + await buildLedger(in: fixture) + + let req = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") + req.predicate = NSPredicate(format: "gameID == %@", fixture.gameID as CVarArg) + #expect(try fixture.persistence.viewContext.count(for: req) > 0) + + // The game finishes; the next build drops the now-useless ledger. + fixture.game.completedAt = Date() + try fixture.persistence.viewContext.save() + await buildLedger(in: fixture) + + #expect(try fixture.persistence.viewContext.count(for: req) == 0) + } }