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:
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)
+ }
}