commit 23e50ac6bfecf87c6c59138b2d435684a9532ec1
parent d2243dc5ff197aeba21f4ac28161038a4d117307
Author: Michael Camilleri <[email protected]>
Date: Sat, 9 May 2026 11:40:23 +0900
Retry pending move flushes until persisted
Leaving a puzzle can happen before the debounce has persisted the latest edit,
and failed flushes could previously drop the in-memory buffer. This commit
flushes moves on puzzle exit and keeps pending edits queued when author
identity or Core Data persistence is unavailable, so they can be retried
instead of lost.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 35 insertions(+), 15 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -390,6 +390,7 @@ private struct PuzzleDisplayView: View {
let movesUpdater = services.movesUpdater
let exitedID = gameID
Task {
+ await movesUpdater.flush()
await presence.clear()
await movesUpdater.noteSessionEnded(gameID: exitedID)
}
diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift
@@ -10,7 +10,7 @@ import Foundation
/// Flush triggers:
/// - trailing-edge debounce (the user has stopped typing);
/// - cell change (focus moves to a different cell);
-/// - explicit `flush()` (app background, game completion, tests).
+/// - explicit `flush()` (view exit, app background, game completion, tests).
actor MovesUpdater {
private struct Key: Hashable {
let gameID: UUID
@@ -140,14 +140,13 @@ actor MovesUpdater {
private func performFlush() async {
guard !buffer.isEmpty else { return }
- // The parent record name embeds the writer's authorID — without it we
- // can't address the row at all. Drop the flush; subsequent enqueues
- // will hit this path again once identity resolves.
+ // The parent record name embeds the writer's authorID; without it we
+ // can't address the row yet. Keep the buffer intact and retry rather
+ // than losing live in-memory edits that have not reached Core Data.
guard let writerAuthorID = await writerAuthorIDProvider(),
!writerAuthorID.isEmpty
else {
- buffer.removeAll(keepingCapacity: true)
- lastCell = nil
+ scheduleDebounce()
return
}
@@ -155,10 +154,14 @@ actor MovesUpdater {
buffer.removeAll(keepingCapacity: true)
lastCell = nil
- let affected = persistAndMerge(
+ guard let affected = persistAndMerge(
snapshot: snapshot,
writerAuthorID: writerAuthorID
- )
+ ) else {
+ buffer.merge(snapshot) { current, _ in current }
+ scheduleDebounce()
+ return
+ }
guard !affected.isEmpty else { return }
await sink(affected)
}
@@ -170,7 +173,7 @@ actor MovesUpdater {
private func persistAndMerge(
snapshot: [Key: Pending],
writerAuthorID: String
- ) -> Set<UUID> {
+ ) -> Set<UUID>? {
let context = persistence.container.newBackgroundContext()
return context.performAndWait {
var byGame: [UUID: [(Key, Pending)]] = [:]
@@ -237,7 +240,7 @@ actor MovesUpdater {
try context.save()
} catch {
print("MovesUpdater: failed to save context: \(error)")
- return []
+ return nil
}
}
return affected
diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift
@@ -16,6 +16,13 @@ struct MovesUpdaterTests {
func append(_ ids: Set<UUID>) { flushes.append(ids) }
}
+ actor MutableAuthor {
+ private var value: String?
+ init(_ value: String?) { self.value = value }
+ func current() -> String? { value }
+ func set(_ newValue: String?) { value = newValue }
+ }
+
private static let writerAuthorID = "alice"
private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
@@ -236,14 +243,16 @@ struct MovesUpdaterTests {
#expect(cells.first?.letterAuthorID == "alice")
}
- @Test("Flush is dropped silently when the writer authorID is nil")
- func dropsFlushWithoutWriter() async throws {
+ @Test("Flush keeps pending edits when the writer authorID is nil")
+ func keepsPendingFlushWithoutWriter() async throws {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
- let updater = makeUpdater(
+ let author = MutableAuthor(nil)
+ let updater = MovesUpdater(
+ debounceInterval: .seconds(10),
persistence: persistence,
- capture: capture,
- writerAuthorID: nil
+ writerAuthorIDProvider: { await author.current() },
+ sink: { await capture.append($0) }
)
await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
@@ -251,5 +260,12 @@ struct MovesUpdaterTests {
#expect(await capture.flushCount == 0)
#expect(movesEntity(gameID: gameID, persistence: persistence) == nil)
+
+ await author.set(Self.writerAuthorID)
+ await updater.flush()
+
+ #expect(await capture.flushCount == 1)
+ let cells = try decodedCells(gameID: gameID, persistence: persistence)
+ #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A")
}
}