commit 7ef94157432de98fd4447b95221ba269af479121
parent c24065995ac2ef2412567b5e9cab5fd173a93edb
Author: Michael Camilleri <[email protected]>
Date: Sat, 30 May 2026 21:28:32 +0900
Stop dropping move-journal entries on Core Data merge conflicts
The move journal recorded undo/redo correctly during a session but lost
entries across a relaunch, so the journal fell behind the grid and the
undo supersession guard — which only applies a restore when the
journal's expected letter still matches the live cell — skipped every
step. Undo appeared to do nothing on a reopened puzzle.
The journal's background context was created with the default
NSErrorMergePolicy. Each appended entry links its GameEntity for cascade
delete, so the context holds a snapshot of that row; meanwhile the sync
engine bumps GameEntity constantly (updatedAt and the like, even with no
iCloud account in the Simulator). By the next journal save the snapshot
is stale, the save fails with a 133020 "Could not merge changes", and
the rollback discards the whole save — including the new entry — while
it lives on in the in-memory list. So play looked fine until the process
died and the unsaved tail was gone.
Give that context NSMergePolicy.mergeByPropertyStoreTrump, matching the
view context's posture: the store's authoritative GameEntity wins on the
fields it changed, and our relationship insert is re-applied, so the
journal write always lands.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -108,6 +108,18 @@ final class MovesJournal {
init(persistence: PersistenceController) {
self.persistence = persistence
self.backgroundContext = persistence.container.newBackgroundContext()
+ // Each appended entry links its `GameEntity` (for cascade-delete),
+ // which touches that row's inverse relationship. Meanwhile the grid
+ // writer and summary backfills bump the same `GameEntity` constantly,
+ // so this context's snapshot of it goes stale between saves. With the
+ // default `NSErrorMergePolicy` that surfaces as a 133020 merge conflict
+ // and the *entire* save — including the new journal row — is rolled
+ // back, silently dropping the entry from disk while it lingers in the
+ // in-memory list. Undo then works for the session but the row is gone
+ // on relaunch, leaving the journal behind the grid. Store-trump keeps
+ // the store's authoritative `GameEntity` and applies only our
+ // relationship insert, so the journal write always lands.
+ self.backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump
}
// MARK: - Recording
diff --git a/Crossmate/Views/HardwareKeyboardInputView.swift b/Crossmate/Views/HardwareKeyboardInputView.swift
@@ -36,9 +36,9 @@ struct HardwareKeyboardInputView: UIViewRepresentable {
}
let undo = UIKeyCommand(input: "z", modifierFlags: .command, action: #selector(handleKeyCommand(_:)))
- undo.discoverabilityTitle = "Undo"
+ undo.discoverabilityTitle = "Undo Move"
let redo = UIKeyCommand(input: "z", modifierFlags: [.command, .shift], action: #selector(handleKeyCommand(_:)))
- redo.discoverabilityTitle = "Redo"
+ redo.discoverabilityTitle = "Redo Move"
return letters + [
undo,
diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/KeyboardView.swift
@@ -164,11 +164,11 @@ struct KeyboardView: View {
.keyWidthMultiplier(metaKeyWidthMultiplier)
.popover(isPresented: $showingOverflow) {
VStack(alignment: .leading, spacing: 0) {
- overflowItem("Undo", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) {
+ overflowItem("Undo Move", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) {
session.undo()
}
- overflowItem("Redo", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) {
+ overflowItem("Redo Move", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) {
session.redo()
}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -827,9 +827,9 @@ private struct PuzzleToolbarModifier: ViewModifier {
private var entryMenu: some View {
Menu {
Section {
- Button("Undo") { session.undo() }
+ Button("Undo Move") { session.undo() }
.disabled(!session.canUndo)
- Button("Redo") { session.redo() }
+ Button("Redo Move") { session.redo() }
.disabled(!session.canRedo)
}