commit 16bd2fc90e1639aa5c70d6311ee7438f7702aff3
parent 17e71e9b4e23fd091e86ff2293900ec3f19a68d8
Author: Michael Camilleri <[email protected]>
Date: Fri, 24 Apr 2026 06:35:04 +0900
Adopt move-log architecture and CKSyncEngine
The current design of Crossmate is incompatible with the throttling limits of
CloudKit. This commit replaces the per-cell-record outbox model with an
append-only move log driven by CKSyncEngine.
A MoveBuffer actor is added that coalesces keystrokes by cell within a debounce
window, assigns Lamport timestamps, persists MoveEntity, and flushes as a
single CKModifyRecordsOperation. CKSyncEngine is used to handle incoming
Move/Snapshot records, replay them into the CellEntity cache, and fire
onRemoteMoves. As a result, this commit also deletes OutboxRecorder,
PushDebouncer, PendingChangePayload, and PendingChange+Helpers. The
PendingChangeEntity is removed as are obsolete token fields from the data
model.
CellEntity is retained as a replay-driven cache so existing views
continue to work unchanged.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Diffstat:
21 files changed, 1135 insertions(+), 2444 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -10,15 +10,14 @@
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; };
- 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
+ 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
- 4A99624B75CDD821BF173621 /* OutboxRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
@@ -30,7 +29,6 @@
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; };
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
- 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */; };
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; };
@@ -46,20 +44,18 @@
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
- C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
- C80AA954E10B5C1A65CAE335 /* PushDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
- D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; };
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; };
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; };
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; };
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; };
+ F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; };
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; };
@@ -92,19 +88,17 @@
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; };
50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; };
+ 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; };
543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; };
5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; };
- 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
- 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDebouncer.swift; sourceTree = "<group>"; };
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; };
87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
- 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDebouncerTests.swift; sourceTree = "<group>"; };
927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; };
93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -118,6 +112,7 @@
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
+ BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; };
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
@@ -129,12 +124,9 @@
D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
- E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; };
- E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.swift"; sourceTree = "<group>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; };
EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; };
- F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxRecorder.swift; sourceTree = "<group>"; };
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; };
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; };
@@ -154,9 +146,8 @@
074C2962E79CAE6C0EA6431A /* Sync */ = {
isa = PBXGroup;
children = (
+ 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */,
F7422F19AA1F1692A98E3602 /* MoveLog.swift */,
- E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */,
- 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */,
@@ -178,10 +169,9 @@
isa = PBXGroup;
children = (
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */,
543481AA9FA32BF14076EB1C /* MoveLogTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
- 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */,
- 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
);
name = Unit;
@@ -212,8 +202,6 @@
children = (
43DC132D49361C56DE79C13E /* GameMutator.swift */,
93EE5BA78566EDED68D846AB /* GameStore.swift */,
- F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */,
- E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */,
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */,
);
path = Persistence;
@@ -398,10 +386,9 @@
buildActionMask = 2147483647;
files = (
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */,
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
- 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */,
- 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
);
@@ -429,6 +416,7 @@
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
+ F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */,
7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */,
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */,
FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */,
@@ -436,14 +424,10 @@
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */,
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */,
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
- 4A99624B75CDD821BF173621 /* OutboxRecorder.swift in Sources */,
- D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */,
- C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
- C80AA954E10B5C1A65CAE335 /* PushDebouncer.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -60,20 +60,8 @@
<relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/>
</entity>
<entity name="SyncStateEntity" representedClassName="SyncStateEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="ckEngineState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
- <attribute name="privateDatabaseToken" optional="YES" attributeType="Binary"/>
- <attribute name="privateZoneToken" optional="YES" attributeType="Binary"/>
- <attribute name="subscriptionCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
- <attribute name="zoneCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
- </entity>
- <entity name="PendingChangeEntity" representedClassName="PendingChangeEntity" syncable="YES" codeGenerationType="class">
- <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
- <attribute name="payload" attributeType="String"/>
- <attribute name="recordName" attributeType="String"/>
- <attribute name="recordType" attributeType="String"/>
- <fetchIndex name="byRecordName">
- <fetchIndexElement property="recordName" type="Binary" order="ascending"/>
- </fetchIndex>
</entity>
</model>
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -127,37 +127,37 @@ final class PlayerSession {
func checkSquare() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- mutator.checkCells([cell], origin: .local)
+ mutator.checkCells([cell])
}
func checkCurrentWord() {
- mutator.checkCells(currentWordCells(), origin: .local)
+ mutator.checkCells(currentWordCells())
}
func checkPuzzle() {
- mutator.checkCells(puzzle.cells.flatMap { $0 }, origin: .local)
+ mutator.checkCells(puzzle.cells.flatMap { $0 })
}
func revealSquare() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- mutator.revealCells([cell], origin: .local)
+ mutator.revealCells([cell])
}
func revealCurrentWord() {
- mutator.revealCells(currentWordCells(), origin: .local)
+ mutator.revealCells(currentWordCells())
}
func revealPuzzle() {
- mutator.revealCells(puzzle.cells.flatMap { $0 }, origin: .local)
+ mutator.revealCells(puzzle.cells.flatMap { $0 })
}
func clearCurrentWord() {
- mutator.clearCells(currentWordCells(), origin: .local)
+ mutator.clearCells(currentWordCells())
}
func clearPuzzle() {
- mutator.clearCells(puzzle.cells.flatMap { $0 }, origin: .local)
+ mutator.clearCells(puzzle.cells.flatMap { $0 })
}
private func currentClueNumber() -> Int? {
@@ -180,7 +180,7 @@ final class PlayerSession {
func enter(_ letter: String) {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- mutator.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local)
+ mutator.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode)
advance()
}
@@ -195,7 +195,7 @@ final class PlayerSession {
if currentEmpty || currentMark.isRevealed {
retreat()
}
- mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, origin: .local)
+ mutator.clearLetter(atRow: selectedRow, atCol: selectedCol)
}
// MARK: - Rebus
@@ -220,7 +220,7 @@ final class PlayerSession {
let value = rebusBuffer
isRebusActive = false
rebusBuffer = ""
- mutator.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local)
+ mutator.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode)
advance()
}
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -1,225 +1,85 @@
-import CoreData
import Foundation
-/// Decoded cell state from an incoming CloudKit record, passed from the
-/// `SyncEngine` actor to the main actor for application through `GameMutator`.
-struct RemoteCellChange: Sendable {
- let gameRecordName: String
- let row: Int
- let col: Int
- let letter: String
- let markKind: Int16
- let checkedWrong: Bool
- let updatedAt: Date?
- let letterAuthorID: String?
-}
-
-/// Unified mutation processor that sits between `PlayerSession` (or the sync
-/// engine) and `Game`. Every mutation flows through here so that:
+/// Unified mutation processor that sits between `PlayerSession` and `Game`.
+/// Every mutation flows through here so that the in-memory `Game` stays
+/// up-to-date for immediate UI feedback, and a corresponding `Move` is emitted
+/// to `MoveBuffer` for durable persistence and CloudKit sync.
///
-/// 1. Timestamps are captured (local) or accepted (remote) for LWW.
-/// 2. The in-memory `Game` is updated.
-/// 3. Changed cells are persisted to Core Data.
-/// 4. For local mutations, a `PendingChangeEntity` is enqueued for upload.
+/// Remote changes no longer flow through here — they arrive via replay from
+/// the sync engine, which writes directly to `CellEntity` and notifies the
+/// store to refresh the in-memory game.
///
/// All methods are `@MainActor` because `Game` is `@MainActor`.
@MainActor
final class GameMutator {
private let game: Game
- private let gameEntity: GameEntity
- private let context: NSManagedObjectContext
-
- /// Wired to SyncEngine in Phase E to trigger a push after local mutations.
- var onLocalMutation: (() -> Void)?
-
- enum Origin {
- case local
- case remote(timestamp: Date, authorID: String?)
- }
+ private let gameID: UUID
+ private let moveBuffer: MoveBuffer?
- init(game: Game, gameEntity: GameEntity, context: NSManagedObjectContext) {
+ init(game: Game, gameID: UUID, moveBuffer: MoveBuffer?) {
self.game = game
- self.gameEntity = gameEntity
- self.context = context
+ self.gameID = gameID
+ self.moveBuffer = moveBuffer
}
// MARK: - Single-cell mutations
- func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, origin: Origin) {
- let now = timestamp(for: origin)
- guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return }
-
+ func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) {
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil)
- game.squares[row][col].updatedAt = now
- game.squares[row][col].letterAuthorID = authorID(for: origin)
-
- persistCell(atRow: row, atCol: col)
- enqueueIfLocal(origin: origin, row: row, col: col)
+ emitMove(atRow: row, atCol: col)
}
- func clearLetter(atRow row: Int, atCol col: Int, origin: Origin) {
- let now = timestamp(for: origin)
- guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return }
-
+ func clearLetter(atRow row: Int, atCol col: Int) {
game.clearLetter(atRow: row, atCol: col)
- game.squares[row][col].updatedAt = now
- game.squares[row][col].letterAuthorID = nil
-
- persistCell(atRow: row, atCol: col)
- enqueueIfLocal(origin: origin, row: row, col: col)
+ emitMove(atRow: row, atCol: col)
}
// MARK: - Bulk mutations
- func checkCells(_ cells: [Puzzle.Cell], origin: Origin) {
- let now = timestamp(for: origin)
- var changed: [Puzzle.Cell] = []
-
- for cell in cells {
- guard !cell.isBlock else { continue }
- guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
- changed.append(cell)
- }
-
- guard !changed.isEmpty else { return }
- game.checkCells(changed)
-
- for cell in changed {
- game.squares[cell.row][cell.col].updatedAt = now
- persistCell(atRow: cell.row, atCol: cell.col)
- enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
+ func checkCells(_ cells: [Puzzle.Cell]) {
+ let applicable = cells.filter { !$0.isBlock }
+ guard !applicable.isEmpty else { return }
+ game.checkCells(applicable)
+ for cell in applicable {
+ emitMove(atRow: cell.row, atCol: cell.col)
}
}
- func revealCells(_ cells: [Puzzle.Cell], origin: Origin) {
- let now = timestamp(for: origin)
- var changed: [Puzzle.Cell] = []
-
- for cell in cells {
- guard !cell.isBlock else { continue }
- guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
- changed.append(cell)
- }
-
- guard !changed.isEmpty else { return }
- game.revealCells(changed)
-
- for cell in changed {
- game.squares[cell.row][cell.col].updatedAt = now
- persistCell(atRow: cell.row, atCol: cell.col)
- enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
+ func revealCells(_ cells: [Puzzle.Cell]) {
+ let applicable = cells.filter { !$0.isBlock }
+ guard !applicable.isEmpty else { return }
+ game.revealCells(applicable)
+ for cell in applicable {
+ emitMove(atRow: cell.row, atCol: cell.col)
}
}
- func clearCells(_ cells: [Puzzle.Cell], origin: Origin) {
- let now = timestamp(for: origin)
- var changed: [Puzzle.Cell] = []
-
- for cell in cells {
- guard !cell.isBlock else { continue }
- guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
- changed.append(cell)
+ func clearCells(_ cells: [Puzzle.Cell]) {
+ let applicable = cells.filter { !$0.isBlock }
+ guard !applicable.isEmpty else { return }
+ game.clearCells(applicable)
+ for cell in applicable {
+ emitMove(atRow: cell.row, atCol: cell.col)
}
-
- guard !changed.isEmpty else { return }
- game.clearCells(changed)
-
- for cell in changed {
- game.squares[cell.row][cell.col].updatedAt = now
- game.squares[cell.row][cell.col].letterAuthorID = nil
- persistCell(atRow: cell.row, atCol: cell.col)
- enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
- }
- }
-
- // MARK: - Remote cell application
-
- /// Applies a single remote cell change to the in-memory `Game`. Core Data
- /// is already up to date (written by `RecordSerializer`), so this only
- /// touches the in-memory model after passing the LWW gate.
- func applyRemoteCell(_ change: RemoteCellChange) {
- let row = change.row
- let col = change.col
- guard row >= 0, row < game.puzzle.height, col >= 0, col < game.puzzle.width else { return }
-
- // LWW: only apply if the remote timestamp is present and at least as
- // new as the local one.
- let localTimestamp = game.squares[row][col].updatedAt
- guard let remoteTimestamp = change.updatedAt,
- localTimestamp == nil || remoteTimestamp >= localTimestamp!
- else { return }
-
- game.squares[row][col].entry = change.letter
- game.squares[row][col].mark = decodeMark(kind: change.markKind, checkedWrong: change.checkedWrong)
- game.squares[row][col].updatedAt = change.updatedAt
- game.squares[row][col].letterAuthorID = change.letterAuthorID
}
// MARK: - Helpers
- private func timestamp(for origin: Origin) -> Date {
- switch origin {
- case .local:
- return Date()
- case .remote(let timestamp, _):
- return timestamp
- }
- }
-
- private func authorID(for origin: Origin) -> String? {
- switch origin {
- case .local:
- return nil // Will be set to the local user's CKRecord.ID in Phase E
- case .remote(_, let authorID):
- return authorID
- }
- }
-
- /// LWW gate: returns `true` if the incoming timestamp is newer than (or
- /// equal to) the cell's current `updatedAt`. Cells that have never been
- /// stamped always accept the incoming value.
- private func shouldApply(row: Int, col: Int, incomingTimestamp: Date) -> Bool {
- guard let existing = game.squares[row][col].updatedAt else { return true }
- return incomingTimestamp >= existing
- }
-
- // MARK: - Core Data persistence
-
- private func persistCell(atRow row: Int, atCol col: Int) {
+ private func emitMove(atRow row: Int, atCol col: Int) {
+ guard let moveBuffer else { return }
let square = game.squares[row][col]
- guard let cellEntity = findCellEntity(row: row, col: col) else { return }
-
- cellEntity.letter = square.entry
- let (kind, wrong) = encodeMark(square.mark)
- cellEntity.markKind = kind
- cellEntity.checkedWrong = wrong
- cellEntity.updatedAt = square.updatedAt
- cellEntity.letterAuthorID = square.letterAuthorID
-
- gameEntity.updatedAt = Date()
- saveContext()
- }
-
- private func findCellEntity(row: Int, col: Int) -> CellEntity? {
- let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? []
- return cellEntities.first { Int($0.row) == row && Int($0.col) == col }
- }
-
- private func saveContext() {
- guard context.hasChanges else { return }
- do {
- try context.save()
- } catch {
- print("GameMutator: failed to save context: \(error)")
- }
- }
-
- private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark {
- switch kind {
- case 1: return .pen(checkedWrong: checkedWrong)
- case 2: return .pencil(checkedWrong: checkedWrong)
- case 3: return .revealed
- default: return .none
+ let (markKind, checkedWrong) = encodeMark(square.mark)
+ let id = gameID
+ let letter = square.entry
+ Task {
+ await moveBuffer.enqueue(
+ gameID: id,
+ row: row, col: col,
+ letter: letter,
+ markKind: markKind,
+ checkedWrong: checkedWrong,
+ authorID: nil
+ )
}
}
@@ -235,42 +95,4 @@ final class GameMutator {
return (3, false)
}
}
-
- // MARK: - Outbox
-
- private func enqueueIfLocal(origin: Origin, row: Int, col: Int) {
- guard case .local = origin else { return }
-
- let square = game.squares[row][col]
- guard let gameID = gameEntity.id else { return }
- let recordName = RecordSerializer.recordName(forCellInGame: gameID, row: row, col: col)
-
- let (markKind, checkedWrong) = encodeMark(square.mark)
- let payload = PendingChangePayload(
- recordType: .cell,
- recordName: recordName,
- letter: square.entry,
- markKind: markKind,
- checkedWrong: checkedWrong,
- updatedAt: square.updatedAt,
- letterAuthorID: square.letterAuthorID,
- parentGameRecordName: RecordSerializer.recordName(forGameID: gameID)
- )
-
- do {
- let jsonData = try JSONEncoder().encode(payload)
- let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
- PendingChangeEntity.upsert(
- recordName: recordName,
- recordType: "Cell",
- payload: jsonString,
- in: context
- )
- saveContext()
- } catch {
- print("GameMutator: failed to encode pending change: \(error)")
- }
-
- onLocalMutation?()
- }
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -78,15 +78,18 @@ final class GameStore {
private(set) var currentMutator: GameMutator?
private(set) var currentEntity: GameEntity?
- /// Called after local work has been enqueued in the pending-change outbox.
- /// The app layer wires this to the sync engine so persistence stays
- /// independent of CloudKit scheduling.
+ /// Set by `AppServices` after construction so `GameMutator` instances have
+ /// a buffer to emit moves into.
@ObservationIgnored
- var onPendingChangesAvailable: (() -> Void)?
+ var moveBuffer: MoveBuffer?
+
+ /// Called when a new game's `ckRecordName` is ready to push. Wired to
+ /// `SyncEngine.enqueueGame` by `AppServices`.
+ @ObservationIgnored
+ var onGameCreated: ((String) -> Void)?
init(persistence: PersistenceController) {
self.persistence = persistence
- repairSyncedGamesIfNeeded()
}
enum LoadError: Error {
@@ -95,6 +98,15 @@ final class GameStore {
case gameNotFound
}
+ // MARK: - Remote update
+
+ /// Re-replays the current game from its move log after remote moves have
+ /// been written into Core Data by the sync engine.
+ func refreshCurrentGame() {
+ guard let game = currentGame, let entity = currentEntity else { return }
+ restore(game: game, from: entity)
+ }
+
// MARK: - Load a specific game
/// Loads a game by its entity ID. Sets it as the current game.
@@ -152,23 +164,8 @@ final class GameStore {
entity.updatedAt = now
entity.ckRecordName = "game-\(gameID.uuidString)"
- for row in puzzle.cells {
- for cell in row where !cell.isBlock {
- let cellEntity = CellEntity(context: context)
- cellEntity.row = Int16(cell.row)
- cellEntity.col = Int16(cell.col)
- cellEntity.letter = ""
- cellEntity.markKind = 0
- cellEntity.checkedWrong = false
- cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
- cellEntity.game = entity
- }
- }
-
- RecordSerializer.enqueueGamePending(for: entity, in: context)
-
try context.save()
- notifyPendingChangesAvailable()
+ onGameCreated?("game-\(gameID.uuidString)")
return gameID
}
@@ -188,28 +185,8 @@ final class GameStore {
currentEntity = nil
}
- let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
- for cellEntity in cellEntities {
- if let recordName = cellEntity.ckRecordName {
- RecordSerializer.enqueueDeletePending(
- recordName: recordName,
- recordType: .deletedCell,
- in: context
- )
- }
- }
-
- if let recordName = entity.ckRecordName {
- RecordSerializer.enqueueDeletePending(
- recordName: recordName,
- recordType: .deletedGame,
- in: context
- )
- }
-
context.delete(entity)
try context.save()
- notifyPendingChangesAvailable()
}
// MARK: - Resign a game
@@ -218,7 +195,7 @@ final class GameStore {
func resignGame(id: UUID) throws {
let (game, mutator) = try loadGame(id: id)
let allCells = game.puzzle.cells.flatMap { $0 }
- mutator.revealCells(allCells, origin: .local)
+ mutator.revealCells(allCells)
guard let entity = currentEntity else { return }
entity.completedAt = Date()
@@ -290,123 +267,102 @@ final class GameStore {
entity.updatedAt = now
entity.ckRecordName = "game-\(gameID.uuidString)"
- for row in puzzle.cells {
- for cell in row where !cell.isBlock {
- let cellEntity = CellEntity(context: context)
- cellEntity.row = Int16(cell.row)
- cellEntity.col = Int16(cell.col)
- cellEntity.letter = ""
- cellEntity.markKind = 0
- cellEntity.checkedWrong = false
- cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
- cellEntity.game = entity
- }
- }
-
- RecordSerializer.enqueueGamePending(for: entity, in: context)
-
try context.save()
- notifyPendingChangesAvailable()
+ onGameCreated?("game-\(gameID.uuidString)")
return (entity, puzzle)
}
- // MARK: - Repair for sync-applied games
-
- /// Fixes up `GameEntity` rows that were materialized by the old sync
- /// apply path, which failed to populate `id`, `createdAt`, `updatedAt`,
- /// and child cells. Runs inline on any code path that lists or loads
- /// games; the predicate targets only broken rows so the steady-state
- /// cost is a single count query.
- private func repairSyncedGamesIfNeeded() {
- let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- request.predicate = NSPredicate(
- format: "ckRecordName != nil AND (id == nil OR cells.@count == 0)"
- )
- guard let broken = try? context.fetch(request), !broken.isEmpty else { return }
-
- // Repair re-materializes state that already lives on CloudKit, so we
- // don't want `OutboxRecorder` to treat it as a local mutation.
- context.userInfo[OutboxRecorder.skipKey] = true
- defer { context.userInfo.removeObject(forKey: OutboxRecorder.skipKey) }
-
- for entity in broken {
- if entity.id == nil,
- let recordName = entity.ckRecordName,
- recordName.hasPrefix("game-") {
- let uuidString = String(recordName.dropFirst("game-".count))
- entity.id = UUID(uuidString: uuidString)
- }
+ private func restore(game: Game, from entity: GameEntity) {
+ let snapshotRequest = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity")
+ snapshotRequest.predicate = NSPredicate(format: "game == %@", entity)
+ let snapshotEntities = (try? context.fetch(snapshotRequest)) ?? []
+
+ let moveRequest = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ moveRequest.predicate = NSPredicate(format: "game == %@", entity)
+ let moveEntities = (try? context.fetch(moveRequest)) ?? []
+
+ guard let gameID = entity.id else { return }
+
+ let snapshots: [Snapshot] = snapshotEntities.compactMap { se in
+ guard let data = se.gridState,
+ let grid = try? MoveLog.decodeGridState(data) else { return nil }
+ return Snapshot(
+ gameID: gameID,
+ upToLamport: se.upToLamport,
+ grid: grid,
+ createdAt: se.createdAt ?? Date()
+ )
+ }
- let now = Date()
- if entity.createdAt == nil { entity.createdAt = now }
- if entity.updatedAt == nil { entity.updatedAt = now }
-
- let existingCellCount = (entity.cells as? Set<CellEntity>)?.count ?? 0
- if existingCellCount == 0,
- let source = entity.puzzleSource,
- let gameID = entity.id,
- let xd = try? XD.parse(source) {
- let puzzle = Puzzle(xd: xd)
- for row in puzzle.cells {
- for cell in row where !cell.isBlock {
- let cellEntity = CellEntity(context: context)
- cellEntity.row = Int16(cell.row)
- cellEntity.col = Int16(cell.col)
- cellEntity.letter = ""
- cellEntity.markKind = 0
- cellEntity.checkedWrong = false
- cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
- cellEntity.game = entity
- }
- }
- }
+ let moves: [Move] = moveEntities.map { me in
+ Move(
+ gameID: gameID,
+ lamport: me.lamport,
+ row: Int(me.row),
+ col: Int(me.col),
+ letter: me.letter ?? "",
+ markKind: me.markKind,
+ checkedWrong: me.checkedWrong,
+ authorID: me.authorID,
+ createdAt: me.createdAt ?? Date()
+ )
}
- try? context.save()
- }
+ let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves)
- private func restore(game: Game, from entity: GameEntity) {
- let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
- for cellEntity in cellEntities {
- let r = Int(cellEntity.row)
- let c = Int(cellEntity.col)
+ for (position, cell) in grid {
+ let r = position.row
+ let c = position.col
guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue }
- game.squares[r][c].entry = cellEntity.letter ?? ""
- game.squares[r][c].mark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong)
- game.squares[r][c].updatedAt = cellEntity.updatedAt
- game.squares[r][c].letterAuthorID = cellEntity.letterAuthorID
+ game.squares[r][c].entry = cell.letter
+ game.squares[r][c].mark = decodeMark(kind: cell.markKind, checkedWrong: cell.checkedWrong)
+ game.squares[r][c].letterAuthorID = cell.authorID
}
+
+ updateCellCache(for: entity, from: grid)
}
- private func makeMutator(game: Game, entity: GameEntity) -> GameMutator {
- let mutator = GameMutator(game: game, gameEntity: entity, context: context)
- mutator.onLocalMutation = { [weak self] in
- self?.notifyPendingChangesAvailable()
+ private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) {
+ let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? []
+ var existing: [GridPosition: CellEntity] = [:]
+ for ce in cellEntities {
+ existing[GridPosition(row: Int(ce.row), col: Int(ce.col))] = ce
}
- return mutator
- }
- private func notifyPendingChangesAvailable() {
- onPendingChangesAvailable?()
- }
+ for (position, cell) in grid {
+ let ce: CellEntity
+ if let found = existing[position] {
+ ce = found
+ } else {
+ ce = CellEntity(context: context)
+ ce.row = Int16(position.row)
+ ce.col = Int16(position.col)
+ ce.game = gameEntity
+ }
+ ce.letter = cell.letter
+ ce.markKind = cell.markKind
+ ce.checkedWrong = cell.checkedWrong
+ ce.letterAuthorID = cell.authorID
+ }
- // MARK: - Remote changes
+ for (position, ce) in existing where grid[position] == nil {
+ ce.letter = ""
+ ce.markKind = 0
+ ce.checkedWrong = false
+ ce.letterAuthorID = nil
+ }
- /// Routes incoming remote cell changes through the current `GameMutator`,
- /// filtering to only the currently loaded game. Core Data is already up to
- /// date at this point (written by `RecordSerializer` on the background
- /// context); this method updates the in-memory `Game` via the single-inbox
- /// path so that hooks like summary regeneration only need one attachment
- /// point.
- func applyRemoteChanges(_ changes: [RemoteCellChange]) {
- guard let mutator = currentMutator, let entity = currentEntity else { return }
- let gameRecordName = entity.ckRecordName ?? ""
+ try? context.save()
+ }
- for change in changes where change.gameRecordName == gameRecordName {
- mutator.applyRemoteCell(change)
+ private func makeMutator(game: Game, entity: GameEntity) -> GameMutator {
+ guard let gameID = entity.id else {
+ fatalError("GameEntity missing id — data model invariant violated")
}
+ return GameMutator(game: game, gameID: gameID, moveBuffer: moveBuffer)
}
+
// MARK: - CellMark coding
private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark {
diff --git a/Crossmate/Persistence/OutboxRecorder.swift b/Crossmate/Persistence/OutboxRecorder.swift
@@ -1,91 +0,0 @@
-import CoreData
-import Foundation
-
-/// Observes Core Data saves on the app's store and enqueues
-/// `PendingChangeEntity` rows for every synced-entity mutation as part of the
-/// same transaction. The aim is a closed loop: any write that reaches Core
-/// Data also produces a sync intent, so new write paths cannot silently skip
-/// the push.
-///
-/// Writes that already represent remote state (the sync engine applying
-/// incoming records, or `GameStore` repairing entities materialized from
-/// CloudKit) must set `context.userInfo[OutboxRecorder.skipKey] = true` so
-/// those changes don't get echoed back to the server.
-final class OutboxRecorder: @unchecked Sendable {
- static let skipKey = "OutboxRecorder.skip"
-
- private let coordinator: NSPersistentStoreCoordinator
-
- init(persistence: PersistenceController) {
- self.coordinator = persistence.container.persistentStoreCoordinator
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(contextWillSave(_:)),
- name: .NSManagedObjectContextWillSave,
- object: nil
- )
- }
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
-
- @objc private func contextWillSave(_ note: Notification) {
- guard let context = note.object as? NSManagedObjectContext,
- context.persistentStoreCoordinator === coordinator,
- context.userInfo[OutboxRecorder.skipKey] as? Bool != true
- else { return }
-
- for object in context.insertedObjects {
- recordUpsert(object, in: context)
- }
- for object in context.updatedObjects {
- recordUpsert(object, in: context)
- }
- for object in context.deletedObjects {
- recordDelete(object, in: context)
- }
- }
-
- private func recordUpsert(
- _ object: NSManagedObject,
- in context: NSManagedObjectContext
- ) {
- switch object {
- case let game as GameEntity:
- RecordSerializer.enqueueGamePending(for: game, in: context)
- case let cell as CellEntity:
- // Untouched cells (never given an `updatedAt` stamp) are handled
- // by the receiving device materializing them from the puzzle
- // source, so we don't push them.
- guard cell.updatedAt != nil else { return }
- RecordSerializer.enqueueCellPending(for: cell, in: context)
- default:
- return
- }
- }
-
- private func recordDelete(
- _ object: NSManagedObject,
- in context: NSManagedObjectContext
- ) {
- switch object {
- case let game as GameEntity:
- guard let recordName = game.ckRecordName else { return }
- RecordSerializer.enqueueDeletePending(
- recordName: recordName,
- recordType: .deletedGame,
- in: context
- )
- case let cell as CellEntity:
- guard let recordName = cell.ckRecordName else { return }
- RecordSerializer.enqueueDeletePending(
- recordName: recordName,
- recordType: .deletedCell,
- in: context
- )
- default:
- return
- }
- }
-}
diff --git a/Crossmate/Persistence/PendingChange+Helpers.swift b/Crossmate/Persistence/PendingChange+Helpers.swift
@@ -1,40 +0,0 @@
-import CoreData
-import Foundation
-
-extension PendingChangeEntity {
- /// Upserts a pending change for the given record name. If a row with the
- /// same `recordName` already exists, its payload and timestamp are updated
- /// (coalescing rapid edits into a single outbox entry). Otherwise a new
- /// row is inserted.
- static func upsert(
- recordName: String,
- recordType: String,
- payload: String,
- in context: NSManagedObjectContext
- ) {
- let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- request.predicate = NSPredicate(format: "recordName == %@", recordName)
- request.fetchLimit = 1
-
- let entity: PendingChangeEntity
- if let existing = try? context.fetch(request).first {
- entity = existing
- } else {
- entity = PendingChangeEntity(context: context)
- entity.recordName = recordName
- entity.recordType = recordType
- }
-
- entity.payload = payload
- entity.createdAt = Date()
- }
-
- /// Fetches up to `limit` pending changes ordered by creation time
- /// (oldest first).
- static func drain(limit: Int, in context: NSManagedObjectContext) -> [PendingChangeEntity] {
- let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
- request.fetchLimit = limit
- return (try? context.fetch(request)) ?? []
- }
-}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -10,15 +10,14 @@ final class AppServices {
let nytAuth: NYTAuthService
let driveMonitor: DriveMonitor
let nytFetcher: NYTPuzzleFetcher
+ let moveBuffer: MoveBuffer
- private let outboxRecorder: OutboxRecorder
- private let pushDebouncer: PushDebouncer
private var started = false
init() {
self.persistence = PersistenceController()
- self.outboxRecorder = OutboxRecorder(persistence: persistence)
- self.store = GameStore(persistence: persistence)
+ let store = GameStore(persistence: persistence)
+ self.store = store
let syncEngine = SyncEngine(
container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"),
persistence: persistence
@@ -28,14 +27,15 @@ final class AppServices {
self.nytAuth = NYTAuthService()
self.driveMonitor = DriveMonitor()
self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() }
- // 1.5s trailing-edge debounce: rapid keystrokes share a single push.
- // Foreground/background transitions flush immediately.
- let monitor = self.syncMonitor
- self.pushDebouncer = PushDebouncer(interval: .milliseconds(1500)) {
- await Self.run("debounced push", monitor: monitor) {
- try await syncEngine.pushChanges()
+ let moveBuffer = MoveBuffer(
+ debounceInterval: .milliseconds(1500),
+ persistence: persistence,
+ sink: { moves in
+ await syncEngine.enqueueMoves(moves)
}
- }
+ )
+ self.moveBuffer = moveBuffer
+ store.moveBuffer = moveBuffer
}
func start(appDelegate: AppDelegate) async {
@@ -45,29 +45,24 @@ final class AppServices {
nytAuth.loadStoredSession()
driveMonitor.start()
- store.onPendingChangesAvailable = { [pushDebouncer] in
- Task {
- await pushDebouncer.schedule()
- }
+ store.onGameCreated = { [syncEngine] ckRecordName in
+ Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) }
}
appDelegate.onRemoteNotification = {
await self.handleRemoteNotification()
}
- await syncEngine.setOnRemoteCellChanges { [store] changes in
- store.applyRemoteChanges(changes)
- }
-
await syncEngine.setTracer { [syncMonitor] message in
syncMonitor.note(message)
}
- await syncEngine.startAccountObserver()
-
- await Self.run("bootstrap", monitor: syncMonitor) {
- try await syncEngine.bootstrap()
+ await syncEngine.setOnRemoteMoves { [store] _ in
+ store.refreshCurrentGame()
}
+
+ await syncEngine.start()
+
await Self.run("initial fetch", monitor: syncMonitor) {
try await syncEngine.fetchChanges()
}
@@ -78,9 +73,7 @@ final class AppServices {
}
func syncOnForeground() async {
- // Flush any debounced keystroke before our own push so the two share
- // one operation against CloudKit instead of racing each other.
- await pushDebouncer.flush()
+ await moveBuffer.flush()
await Self.run("foreground fetch", monitor: syncMonitor) {
try await syncEngine.fetchChanges()
}
@@ -90,10 +83,10 @@ final class AppServices {
await refreshSnapshot()
}
- /// Flushes any debounced push so pending edits reach CloudKit before the
- /// app suspends. Called from the scene-phase observer on background.
+ /// Flushes any buffered keystrokes so pending moves reach CloudKit before
+ /// the app suspends.
func syncOnBackground() async {
- await pushDebouncer.flush()
+ await moveBuffer.flush()
}
func handleOpenURL(_ url: URL) -> UUID? {
diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift
@@ -0,0 +1,193 @@
+import CoreData
+import Foundation
+
+/// In-memory staging area for cell edits. Collapses rapid same-cell edits
+/// down to one move, assigns lamports at flush time from the game's
+/// `lamportHighWater`, and writes `MoveEntity` rows in a single background
+/// transaction. Flushed moves are handed to an injected sink — wired to
+/// `CKSyncEngine` in production, stubbed in tests.
+///
+/// Flush triggers:
+/// - trailing-edge debounce (the user has stopped typing);
+/// - cell change (focus moves to a different cell, so the previous cell's
+/// final value gets a lamport before new edits start accumulating);
+/// - explicit `flush()` (app background, game completion, tests).
+actor MoveBuffer {
+ private struct Key: Hashable {
+ let gameID: UUID
+ let row: Int
+ let col: Int
+ }
+
+ private struct Pending {
+ var letter: String
+ var markKind: Int16
+ var checkedWrong: Bool
+ var authorID: String?
+ var enqueuedAt: Date
+ }
+
+ private let debounceInterval: Duration
+ private let persistence: PersistenceController
+ private let sink: @Sendable ([Move]) async -> Void
+
+ private var buffer: [Key: Pending] = [:]
+ /// Insertion order so that lamports within a single flush are assigned
+ /// in the order edits were made rather than whatever the dictionary
+ /// happens to iterate.
+ private var order: [Key] = []
+ /// The cell most recently enqueued. A subsequent enqueue targeting a
+ /// different cell flushes first; subsequent enqueues for the same cell
+ /// replace the pending value without flushing.
+ private var lastCell: Key?
+ private var debounceTask: Task<Void, Never>?
+
+ init(
+ debounceInterval: Duration = .milliseconds(1500),
+ persistence: PersistenceController,
+ sink: @escaping @Sendable ([Move]) async -> Void
+ ) {
+ self.debounceInterval = debounceInterval
+ self.persistence = persistence
+ self.sink = sink
+ }
+
+ /// Registers a cell edit. If the edit targets a different cell than the
+ /// previous enqueue, the previous cell is flushed first so the resulting
+ /// lamport order matches the user's editing order.
+ func enqueue(
+ gameID: UUID,
+ row: Int,
+ col: Int,
+ letter: String,
+ markKind: Int16,
+ checkedWrong: Bool,
+ authorID: String?
+ ) async {
+ let key = Key(gameID: gameID, row: row, col: col)
+
+ if let lastCell, lastCell != key {
+ await performFlush()
+ }
+
+ if buffer[key] == nil {
+ order.append(key)
+ }
+ buffer[key] = Pending(
+ letter: letter,
+ markKind: markKind,
+ checkedWrong: checkedWrong,
+ authorID: authorID,
+ enqueuedAt: Date()
+ )
+ lastCell = key
+ scheduleDebounce()
+ }
+
+ /// Flushes any pending edits immediately and cancels the debounce. Safe
+ /// to call when the buffer is empty.
+ func flush() async {
+ debounceTask?.cancel()
+ debounceTask = nil
+ await performFlush()
+ }
+
+ private func scheduleDebounce() {
+ debounceTask?.cancel()
+ let interval = debounceInterval
+ debounceTask = Task { [weak self] in
+ try? await Task.sleep(for: interval)
+ if Task.isCancelled { return }
+ await self?.debouncedFlush()
+ }
+ }
+
+ private func debouncedFlush() async {
+ debounceTask = nil
+ await performFlush()
+ }
+
+ private func performFlush() async {
+ guard !buffer.isEmpty else { return }
+
+ let snapshot = buffer
+ let snapshotOrder = order
+ buffer.removeAll(keepingCapacity: true)
+ order.removeAll(keepingCapacity: true)
+ lastCell = nil
+
+ let moves = persistAndAssignLamports(snapshot: snapshot, order: snapshotOrder)
+ guard !moves.isEmpty else { return }
+ await sink(moves)
+ }
+
+ /// Allocates lamports from each game's `lamportHighWater`, writes
+ /// `MoveEntity` rows, and bumps the high-water — all inside a single
+ /// background-context transaction so a crash can't leave the high-water
+ /// out of sync with the written moves.
+ private func persistAndAssignLamports(
+ snapshot: [Key: Pending],
+ order: [Key]
+ ) -> [Move] {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ var moves: [Move] = []
+ var gamesByID: [UUID: GameEntity] = [:]
+
+ for key in order {
+ guard let pending = snapshot[key] else { continue }
+
+ let game: GameEntity
+ if let cached = gamesByID[key.gameID] {
+ game = cached
+ } else {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", key.gameID as CVarArg)
+ request.fetchLimit = 1
+ guard let found = try? context.fetch(request).first else { continue }
+ gamesByID[key.gameID] = found
+ game = found
+ }
+
+ let lamport = game.lamportHighWater + 1
+ game.lamportHighWater = lamport
+
+ let entity = MoveEntity(context: context)
+ entity.game = game
+ entity.lamport = lamport
+ entity.row = Int16(key.row)
+ entity.col = Int16(key.col)
+ entity.letter = pending.letter
+ entity.markKind = pending.markKind
+ entity.checkedWrong = pending.checkedWrong
+ entity.authorID = pending.authorID
+ entity.createdAt = pending.enqueuedAt
+ entity.ckRecordName = RecordSerializer.recordName(
+ forMoveInGame: key.gameID,
+ lamport: lamport
+ )
+
+ moves.append(Move(
+ gameID: key.gameID,
+ lamport: lamport,
+ row: key.row,
+ col: key.col,
+ letter: pending.letter,
+ markKind: pending.markKind,
+ checkedWrong: pending.checkedWrong,
+ authorID: pending.authorID,
+ createdAt: pending.enqueuedAt
+ ))
+ }
+
+ if context.hasChanges {
+ do {
+ try context.save()
+ } catch {
+ print("MoveBuffer: failed to save context: \(error)")
+ }
+ }
+ return moves
+ }
+ }
+}
diff --git a/Crossmate/Sync/PendingChangePayload.swift b/Crossmate/Sync/PendingChangePayload.swift
@@ -1,29 +0,0 @@
-import Foundation
-
-/// Codable snapshot stored as JSON in `PendingChangeEntity.payload`. Captures
-/// everything the upload loop needs to build a `CKRecord` without going back
-/// to Core Data or the in-memory model.
-struct PendingChangePayload: Codable {
- enum RecordType: String, Codable {
- case game = "Game"
- case cell = "Cell"
- case deletedGame = "DeletedGame"
- case deletedCell = "DeletedCell"
- }
-
- let recordType: RecordType
- let recordName: String
-
- // Cell fields (nil when recordType == .game)
- var letter: String?
- var markKind: Int16?
- var checkedWrong: Bool?
- var updatedAt: Date?
- var letterAuthorID: String?
- var parentGameRecordName: String?
-
- // Game fields (nil when recordType == .cell)
- var title: String?
- var puzzleSource: String?
- var completedAt: Date?
-}
diff --git a/Crossmate/Sync/PushDebouncer.swift b/Crossmate/Sync/PushDebouncer.swift
@@ -1,44 +0,0 @@
-import Foundation
-
-/// Coalesces rapid push triggers into a single delayed call. Every keystroke
-/// notifies the debouncer; the underlying `pushChanges()` action runs at most
-/// once per `interval` after the last trigger. `flush()` bypasses the delay
-/// and runs the action immediately — used when the app backgrounds or comes
-/// to the foreground and we want sync to catch up without waiting.
-actor PushDebouncer {
- private let interval: Duration
- private let action: @Sendable () async -> Void
- private var pending: Task<Void, Never>?
-
- init(interval: Duration, action: @escaping @Sendable () async -> Void) {
- self.interval = interval
- self.action = action
- }
-
- /// Schedule the action to run after `interval`. Repeated calls within the
- /// window replace the pending task, extending the wait (trailing-edge
- /// debounce).
- func schedule() {
- pending?.cancel()
- let interval = interval
- let action = action
- pending = Task { [weak self] in
- try? await Task.sleep(for: interval)
- if Task.isCancelled { return }
- await self?.clearPending()
- await action()
- }
- }
-
- /// Cancels any pending schedule and runs the action now. Safe to call
- /// even if nothing is scheduled.
- func flush() async {
- pending?.cancel()
- pending = nil
- await action()
- }
-
- private func clearPending() {
- pending = nil
- }
-}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -13,10 +13,6 @@ enum RecordSerializer {
"game-\(gameID.uuidString)"
}
- static func recordName(forCellInGame gameID: UUID, row: Int, col: Int) -> String {
- "cell-\(gameID.uuidString)-\(row)-\(col)"
- }
-
static func recordName(forMoveInGame gameID: UUID, lamport: Int64) -> String {
"move-\(gameID.uuidString)-\(lamport)"
}
@@ -35,62 +31,6 @@ enum RecordSerializer {
CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
}
- // MARK: - Building CKRecords from payloads
-
- static func gameRecord(
- from payload: PendingChangePayload,
- zone: CKRecordZone.ID,
- systemFields: Data?
- ) -> CKRecord {
- let record = restoreOrCreate(
- recordType: "Game",
- recordName: payload.recordName,
- zone: zone,
- systemFields: systemFields
- )
-
- record["title"] = payload.title as CKRecordValue?
- record["completedAt"] = payload.completedAt as CKRecordValue?
-
- // Puzzle source is stored as a CKAsset (written to a temp file)
- // so it doesn't count against the per-record field size limit.
- if let source = payload.puzzleSource {
- let tempURL = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
- .appendingPathExtension("xd")
- try? source.write(to: tempURL, atomically: true, encoding: .utf8)
- record["puzzleSource"] = CKAsset(fileURL: tempURL)
- }
-
- return record
- }
-
- static func cellRecord(
- from payload: PendingChangePayload,
- zone: CKRecordZone.ID,
- systemFields: Data?
- ) -> CKRecord {
- let record = restoreOrCreate(
- recordType: "Cell",
- recordName: payload.recordName,
- zone: zone,
- systemFields: systemFields
- )
-
- record["letter"] = payload.letter as CKRecordValue?
- record["markKind"] = (payload.markKind ?? 0) as CKRecordValue
- record["checkedWrong"] = (payload.checkedWrong ?? false) as CKRecordValue
- record["updatedAt"] = payload.updatedAt as CKRecordValue?
- record["letterAuthorID"] = payload.letterAuthorID as CKRecordValue?
-
- if let parentName = payload.parentGameRecordName {
- let parentID = CKRecord.ID(recordName: parentName, zoneID: zone)
- record.parent = CKRecord.Reference(recordID: parentID, action: .none)
- }
-
- return record
- }
-
// MARK: - Move / Snapshot record building
static func moveRecord(
@@ -267,161 +207,6 @@ enum RecordSerializer {
return entity
}
- static func applyCellRecord(
- _ record: CKRecord,
- to context: NSManagedObjectContext,
- game: GameEntity
- ) -> CellEntity {
- let recordName = record.recordID.recordName
- let entity = fetchOrCreateCell(
- recordName: recordName,
- in: context,
- game: game
- )
-
- // System fields are always server-authoritative: we need the latest
- // change tag for the next push, regardless of who wins on content.
- entity.ckRecordName = recordName
- entity.ckSystemFields = encodeSystemFields(of: record)
-
- let serverUpdatedAt = record["updatedAt"] as? Date ?? .distantPast
- if localCellWins(
- recordName: recordName,
- localEntityUpdatedAt: entity.updatedAt,
- serverUpdatedAt: serverUpdatedAt,
- in: context
- ) {
- return entity
- }
-
- entity.letter = record["letter"] as? String ?? ""
- entity.markKind = record["markKind"] as? Int16 ?? 0
- entity.checkedWrong = record["checkedWrong"] as? Bool ?? false
- entity.updatedAt = record["updatedAt"] as? Date
- entity.letterAuthorID = record["letterAuthorID"] as? String
-
- return entity
- }
-
- /// Decides whether the local copy of a cell should be preserved rather
- /// than overwritten by an incoming server record. A pending outbox entry
- /// at or after the server's `updatedAt` means the user has unpushed
- /// local state that must not be clobbered; a locally-newer `updatedAt`
- /// is a defensive fallback for cases where the outbox has already been
- /// drained but the push has not yet been confirmed by the server.
- static func localCellWins(
- recordName: String,
- localEntityUpdatedAt: Date?,
- serverUpdatedAt: Date,
- in context: NSManagedObjectContext
- ) -> Bool {
- let pendingRequest = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- pendingRequest.predicate = NSPredicate(format: "recordName == %@", recordName)
- pendingRequest.fetchLimit = 1
- if let pending = try? context.fetch(pendingRequest).first,
- let payloadJSON = pending.payload,
- let data = payloadJSON.data(using: .utf8),
- let payload = try? JSONDecoder().decode(PendingChangePayload.self, from: data) {
- let localUpdatedAt = payload.updatedAt ?? .distantPast
- return localUpdatedAt >= serverUpdatedAt
- }
-
- if let local = localEntityUpdatedAt, local > serverUpdatedAt {
- return true
- }
-
- return false
- }
-
- // MARK: - Pending change construction
-
- /// Enqueues a Game pending change for the given entity. Used both when a
- /// new game is created locally and as a recovery step for games that were
- /// created before the sync engine could push them.
- static func enqueueGamePending(
- for entity: GameEntity,
- in context: NSManagedObjectContext
- ) {
- guard let gameID = entity.id else { return }
- let recordName = recordName(forGameID: gameID)
-
- let payload = PendingChangePayload(
- recordType: .game,
- recordName: recordName,
- title: entity.title,
- puzzleSource: entity.puzzleSource,
- completedAt: entity.completedAt
- )
-
- guard let jsonData = try? JSONEncoder().encode(payload),
- let jsonString = String(data: jsonData, encoding: .utf8)
- else { return }
-
- PendingChangeEntity.upsert(
- recordName: recordName,
- recordType: "Game",
- payload: jsonString,
- in: context
- )
- }
-
- /// Enqueues a Cell pending change built directly from a `CellEntity`.
- /// Used by `OutboxRecorder` so Core Data state is the single source of
- /// truth for the outgoing payload.
- static func enqueueCellPending(
- for cell: CellEntity,
- in context: NSManagedObjectContext
- ) {
- guard let cellRecordName = cell.ckRecordName,
- let gameEntity = cell.game,
- let gameID = gameEntity.id
- else { return }
-
- let payload = PendingChangePayload(
- recordType: .cell,
- recordName: cellRecordName,
- letter: cell.letter,
- markKind: cell.markKind,
- checkedWrong: cell.checkedWrong,
- updatedAt: cell.updatedAt,
- letterAuthorID: cell.letterAuthorID,
- parentGameRecordName: recordName(forGameID: gameID)
- )
-
- guard let jsonData = try? JSONEncoder().encode(payload),
- let jsonString = String(data: jsonData, encoding: .utf8)
- else { return }
-
- PendingChangeEntity.upsert(
- recordName: cellRecordName,
- recordType: "Cell",
- payload: jsonString,
- in: context
- )
- }
-
- static func enqueueDeletePending(
- recordName: String,
- recordType: PendingChangePayload.RecordType,
- in context: NSManagedObjectContext
- ) {
- let payload = PendingChangePayload(
- recordType: recordType,
- recordName: recordName
- )
-
- guard let jsonData = try? JSONEncoder().encode(payload),
- let jsonString = String(data: jsonData, encoding: .utf8)
- else { return }
-
- PendingChangeEntity.upsert(
- recordName: recordName,
- recordType: recordType.rawValue,
- payload: jsonString,
- in: context
- )
- }
-
// MARK: - System fields encode/decode
static func encodeSystemFields(of record: CKRecord) -> Data? {
@@ -470,34 +255,4 @@ enum RecordSerializer {
return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context)
}
- private static func fetchOrCreateCell(
- recordName: String,
- in context: NSManagedObjectContext,
- game: GameEntity
- ) -> CellEntity {
- // Try to find by ckRecordName first
- let cells = (game.cells as? Set<CellEntity>) ?? []
- if let existing = cells.first(where: { $0.ckRecordName == recordName }) {
- return existing
- }
-
- // Parse row/col from record name (format: "cell-<uuid>-<row>-<col>")
- let parts = recordName.split(separator: "-")
- let parsedRow: Int16? = parts.count >= 2 ? Int16(parts[parts.count - 2]) : nil
- let parsedCol: Int16? = parts.count >= 2 ? Int16(parts[parts.count - 1]) : nil
-
- if let parsedRow, let parsedCol,
- let existing = cells.first(where: { $0.row == parsedRow && $0.col == parsedCol }) {
- return existing
- }
-
- // Create new. Seed row/col from the record name so that cells
- // first-seen via sync have their coordinates set (otherwise they
- // default to 0/0 and collide with the top-left cell).
- let entity = CellEntity(context: context)
- entity.game = game
- if let parsedRow { entity.row = parsedRow }
- if let parsedCol { entity.col = parsedCol }
- return entity
- }
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -7,1059 +7,621 @@ extension EnvironmentValues {
@Entry var syncEngine: SyncEngine? = nil
}
-/// Owns the CloudKit container, custom zone, and sync lifecycle. All CloudKit
-/// operations run on this actor's serial executor, keeping token reads and
-/// writes race-free.
+/// Owns the CloudKit sync lifecycle via `CKSyncEngine`. Zone creation,
+/// subscription setup, change-token management, batching, and retry are all
+/// delegated to the framework. This actor's job is to:
+///
+/// - Start and persist the engine's state across launches.
+/// - Translate outbound edits (from `MoveBuffer`) into pending record zone
+/// changes that CKSyncEngine will batch and send.
+/// - Apply incoming `Move` and `Snapshot` records to Core Data and replay
+/// them onto the `CellEntity` cache.
+/// - Notify the main actor so the in-memory `Game` stays current.
actor SyncEngine {
- private enum PushResult {
- case saved(CKRecord)
- case deleted
- case failed(Error)
- }
-
- private struct DeletedRecord {
- let recordID: CKRecord.ID
- let recordType: String?
- }
-
let container: CKContainer
- let privateDatabase: CKDatabase
let persistence: PersistenceController
- /// Starts life with `CKCurrentUserDefaultName` as the owner placeholder
- /// and is rebuilt with the user's real owner record ID the first time
- /// `resolveOwnerIfNeeded()` runs. Using the real owner up front would
- /// block init on a network round-trip, so we resolve lazily instead.
- /// Once resolved, equality checks against zone IDs returned by the
- /// server (which always use the real owner) behave correctly.
- private var zoneID: CKRecordZone.ID
- private var ownerResolved: Bool = false
-
- /// Long-lived task that observes `.CKAccountChanged` and re-runs sync
- /// whenever the user signs in or out. Installed by `startAccountObserver`.
- private var accountObserverTask: Task<Void, Never>?
-
- /// Called on the MainActor with decoded cell changes after remote records
- /// have been applied to Core Data. Wired up in CrossmateApp to route
- /// through GameStore → GameMutator (the single inbox).
- private var onRemoteCellChanges: (@MainActor @Sendable ([RemoteCellChange]) -> Void)?
-
- func setOnRemoteCellChanges(_ callback: @MainActor @Sendable @escaping ([RemoteCellChange]) -> Void) {
- onRemoteCellChanges = callback
- }
+ private var engine: CKSyncEngine?
- /// Optional tracer for diagnostic messages emitted from inside the engine.
- /// Wired up to `SyncMonitor.note` in CrossmateApp so traces appear in the
- /// on-device diagnostics view.
+ private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
- /// The task running the current `performPushChanges`. Second callers
- /// await this task's completion instead of starting a second push, so
- /// overlapping triggers coalesce into one operation against CloudKit.
- private var currentPushTask: Task<Void, Error>?
-
- func setTracer(_ callback: @MainActor @Sendable @escaping (String) -> Void) {
- tracer = callback
+ func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
+ tracer = t
}
- private func trace(_ message: String) async {
- guard let tracer else { return }
- await tracer(message)
+ func setOnRemoteMoves(_ cb: @MainActor @Sendable @escaping ([Move]) async -> Void) {
+ onRemoteMoves = cb
}
init(container: CKContainer, persistence: PersistenceController) {
self.container = container
- self.privateDatabase = container.privateCloudDatabase
self.persistence = persistence
- self.zoneID = RecordSerializer.zoneID()
- }
-
- // MARK: - Account observer
-
- /// Starts listening for `.CKAccountChanged` so sign-in / sign-out events
- /// trigger a fresh bootstrap + fetch + push without needing an app
- /// relaunch. Idempotent.
- func startAccountObserver() {
- guard accountObserverTask == nil else { return }
- accountObserverTask = Task { [weak self] in
- let notifications = NotificationCenter.default.notifications(named: .CKAccountChanged)
- for await _ in notifications {
- guard let self else { return }
- await self.handleAccountChange()
- }
- }
}
- private func handleAccountChange() async {
- await trace("account changed: re-resolving owner and syncing")
- // Force owner re-resolution in case the user switched iCloud accounts.
- ownerResolved = false
- zoneID = RecordSerializer.zoneID()
+ // MARK: - Lifecycle
- do {
- try await bootstrap()
- try await fetchChanges()
- try await pushChanges()
- } catch {
- await trace("account-change sync failed: \(describe(error))")
+ /// Creates the `CKSyncEngine`, restoring previously-saved state so
+ /// pending changes and change tokens survive restarts. Call once after
+ /// wiring callbacks.
+ func start() {
+ let bgCtx = persistence.container.newBackgroundContext()
+ bgCtx.userInfo["OutboxRecorder.skip"] = true
+ let saved: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
+ guard let data = SyncStateEntity.current(in: bgCtx).ckEngineState else { return nil }
+ return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
}
+ let configuration = CKSyncEngine.Configuration(
+ database: container.privateCloudDatabase,
+ stateSerialization: saved,
+ delegate: self
+ )
+ engine = CKSyncEngine(configuration)
}
- // MARK: - Account status
+ // MARK: - Outbound
- /// Returns `true` only when the user has an iCloud account signed in and
- /// available. `bootstrap`, `pushChanges`, and `fetchChanges` gate on this
- /// so the simulator (and signed-out users) stop generating "Not
- /// Authenticated" noise.
- private func accountAvailable() async -> Bool {
- do {
- let status = try await container.accountStatus()
- switch status {
- case .available:
- return true
- case .noAccount, .restricted, .couldNotDetermine, .temporarilyUnavailable:
- await trace("account status: \(describe(status)), skipping sync")
- return false
- @unknown default:
- await trace("account status: unknown raw=\(status.rawValue), skipping sync")
- return false
- }
- } catch {
- await trace("account status: \(describe(error))")
- return false
- }
+ /// Registers Move records as pending sends. Called by the `MoveBuffer`
+ /// sink after lamports are assigned and `MoveEntity` rows are persisted.
+ func enqueueMoves(_ moves: [Move]) {
+ guard let engine else { return }
+ let zoneID = RecordSerializer.zoneID()
+ engine.state.add(pendingRecordZoneChanges: moves.map { move in
+ let name = RecordSerializer.recordName(forMoveInGame: move.gameID, lamport: move.lamport)
+ return .saveRecord(CKRecord.ID(recordName: name, zoneID: zoneID))
+ })
}
- private nonisolated func describe(_ status: CKAccountStatus) -> String {
- switch status {
- case .available: return "available"
- case .noAccount: return "noAccount"
- case .restricted: return "restricted"
- case .couldNotDetermine: return "couldNotDetermine"
- case .temporarilyUnavailable: return "temporarilyUnavailable"
- @unknown default: return "unknown"
- }
+ /// Registers a Game record as a pending send. Called when a new game is
+ /// created locally so the parent record exists in CloudKit before its
+ /// child Move records arrive.
+ func enqueueGame(ckRecordName: String) {
+ guard let engine else { return }
+ let zoneID = RecordSerializer.zoneID()
+ let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: zoneID)
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
- // MARK: - Owner resolution
-
- /// Replaces the placeholder owner in `zoneID` with the real user record
- /// ID on first use. Must be called before any operation that compares
- /// local zone IDs against server-returned ones (fetch database changes,
- /// fetch zone changes, push) — otherwise equality checks silently fail
- /// because the server always stamps zones with the resolved owner.
- private func resolveOwnerIfNeeded() async throws {
- guard !ownerResolved else { return }
- let userRecordID = try await container.userRecordID()
- zoneID = CKRecordZone.ID(zoneName: zoneID.zoneName, ownerName: userRecordID.recordName)
- ownerResolved = true
- await trace("resolveOwner: zoneID now \(zoneID.zoneName)/\(shortOwner(zoneID.ownerName))")
- }
+ // MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
- private nonisolated func shortOwner(_ name: String) -> String {
- // Shorten long owner record IDs so diagnostics stay readable.
- // Keeps the last 8 characters so both devices can be compared visually.
- guard name.count > 10 else { return name }
- return "…" + String(name.suffix(8))
+ func fetchChanges() async throws {
+ try await engine?.fetchChanges()
}
- // MARK: - Bootstrap
-
- /// Ensures the custom zone exists in the private database. Idempotent —
- /// safe to call on every launch. Skips the network call if the zone has
- /// already been created (tracked via `SyncStateEntity`).
- func bootstrap() async throws {
- guard await accountAvailable() else { return }
- try await resolveOwnerIfNeeded()
+ func pushChanges() async throws {
+ try await engine?.sendChanges()
+ }
- let context = persistence.container.newBackgroundContext()
- context.userInfo[OutboxRecorder.skipKey] = true
- let alreadyCreated: Bool = context.performAndWait {
- SyncStateEntity.current(in: context).zoneCreated
- }
+ // MARK: - Diagnostics
- guard !alreadyCreated else {
- await trace("bootstrap: zone already created, skipping")
- return
- }
- await trace("bootstrap: creating zone \(zoneID.zoneName)")
+ struct DiagnosticSnapshot: Sendable {
+ let accountStatus: CKAccountStatus
+ let engineRunning: Bool
+ let pendingChangesCount: Int
+ }
- let zone = RecordSerializer.zone()
- let operation = CKModifyRecordZonesOperation(
- recordZonesToSave: [zone],
- recordZoneIDsToDelete: nil
+ func diagnosticSnapshot() async -> DiagnosticSnapshot {
+ let status: CKAccountStatus
+ do { status = try await container.accountStatus() }
+ catch { status = .couldNotDetermine }
+ let running = engine != nil
+ let pending = engine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
+ return DiagnosticSnapshot(
+ accountStatus: status,
+ engineRunning: running,
+ pendingChangesCount: pending
)
- operation.qualityOfService = .utility
-
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
- operation.modifyRecordZonesResultBlock = { result in
- switch result {
- case .success:
- continuation.resume()
- case .failure(let error):
- continuation.resume(throwing: error)
- }
- }
- privateDatabase.add(operation)
- }
-
- context.performAndWait {
- SyncStateEntity.current(in: context).zoneCreated = true
- try? context.save()
- }
-
- // Create the database subscription for silent push notifications
- try await createSubscriptionIfNeeded(context: context)
}
- /// Creates a `CKDatabaseSubscription` for silent push so that remote
- /// changes trigger a fetch. Idempotent — skipped if already created.
- private func createSubscriptionIfNeeded(context: NSManagedObjectContext) async throws {
- let alreadyCreated: Bool = context.performAndWait {
- SyncStateEntity.current(in: context).subscriptionCreated
+ /// Runs a series of lightweight CloudKit probes and returns human-readable
+ /// (name, result) pairs for display in the diagnostics view.
+ func probeContainer() async -> [(name: String, result: String)] {
+ var results: [(String, String)] = []
+ results.append(("containerIdentifier", container.containerIdentifier ?? "nil"))
+ do {
+ let s = try await container.accountStatus()
+ results.append(("accountStatus", describeStatus(s)))
+ } catch {
+ results.append(("accountStatus", describe(error)))
}
- guard !alreadyCreated else { return }
-
- let subscription = CKDatabaseSubscription(subscriptionID: "private-changes")
- let notificationInfo = CKSubscription.NotificationInfo()
- notificationInfo.shouldSendContentAvailable = true
- subscription.notificationInfo = notificationInfo
-
- let operation = CKModifySubscriptionsOperation(
- subscriptionsToSave: [subscription],
- subscriptionIDsToDelete: nil
- )
- operation.qualityOfService = .utility
-
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
- operation.modifySubscriptionsResultBlock = { result in
- switch result {
- case .success:
- continuation.resume()
- case .failure(let error):
- continuation.resume(throwing: error)
- }
- }
- privateDatabase.add(operation)
+ do {
+ let id = try await container.userRecordID()
+ results.append(("userRecordID", id.recordName))
+ } catch {
+ results.append(("userRecordID", describe(error)))
}
-
- context.performAndWait {
- SyncStateEntity.current(in: context).subscriptionCreated = true
- try? context.save()
+ do {
+ let zones = try await container.privateCloudDatabase.allRecordZones()
+ let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
+ results.append(("allRecordZones", "\(zones.count) zone(s): [\(names)]"))
+ } catch {
+ results.append(("allRecordZones", describe(error)))
}
+ return results
}
- // MARK: - Push
-
- /// Drains the `PendingChangeEntity` outbox and pushes records to CloudKit.
- /// Single-flight: if a push is already running, callers await that task
- /// rather than starting an overlapping operation. The drain loop inside
- /// `performPushChanges` picks up any pending changes enqueued during the
- /// push, so no second call is needed to ship in-flight edits.
- func pushChanges() async throws {
- if let existing = currentPushTask {
- try await existing.value
- return
- }
- let task = Task { [weak self] () async throws -> Void in
- guard let self else { return }
- try await self.performPushChanges()
+ /// Clears the saved engine state so the next `start()` creates a fresh
+ /// engine. Pending records already in CloudKit are unaffected.
+ func resetSyncState() async {
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.userInfo["OutboxRecorder.skip"] = true
+ ctx.performAndWait {
+ SyncStateEntity.current(in: ctx).ckEngineState = nil
+ try? ctx.save()
}
- currentPushTask = task
- defer { currentPushTask = nil }
- try await task.value
}
- private func performPushChanges() async throws {
- guard await accountAvailable() else { return }
- try await resolveOwnerIfNeeded()
-
- let context = persistence.container.newBackgroundContext()
- context.userInfo[OutboxRecorder.skipKey] = true
- var serverWinsCellChanges: [RemoteCellChange] = []
- var iteration = 0
-
- await trace("push: enter")
-
- while true {
- iteration += 1
- await trace("push[\(iteration)]: draining outbox")
-
- let pendingCount: Int = context.performAndWait {
- let req = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- return (try? context.count(for: req)) ?? -1
- }
- await trace("push[\(iteration)]: outbox count=\(pendingCount)")
-
- let batch: [(recordName: String, recordType: String, payload: PendingChangePayload, systemFields: Data?)] =
- context.performAndWait {
- let pending = PendingChangeEntity.drain(limit: 400, in: context)
- guard !pending.isEmpty else { return [] }
-
- return pending.compactMap { entity in
- guard let payloadJSON = entity.payload,
- let recordName = entity.recordName,
- let recordType = entity.recordType,
- let data = payloadJSON.data(using: .utf8),
- let payload = try? JSONDecoder().decode(PendingChangePayload.self, from: data)
- else { return nil }
-
- // Look up ckSystemFields from the corresponding entity
- let systemFields = self.lookupSystemFields(
- recordName: recordName,
- recordType: recordType,
- in: context
- )
-
- return (recordName, recordType, payload, systemFields)
- }
- }
-
- await trace("push[\(iteration)]: batch size=\(batch.count)")
-
- if batch.isEmpty && pendingCount > 0 {
- await trace("push[\(iteration)]: WARNING outbox has \(pendingCount) but batch is empty (decode/drain mismatch)")
- break
- }
- guard !batch.isEmpty else {
- await trace("push[\(iteration)]: outbox empty, exiting")
- break
- }
+ // MARK: - Private helpers
- // Sort saves parent-first and deletes child-first. CloudKit
- // rejects child saves whose parent doesn't exist; deleting child
- // records first avoids leaving orphaned cells behind a deleted
- // game.
- let orderedBatch = batch.sorted { lhs, rhs in
- switch (lhs.payload.recordType, rhs.payload.recordType) {
- case (.game, .cell), (.game, .deletedCell), (.game, .deletedGame):
- return true
- case (.cell, .deletedCell), (.cell, .deletedGame):
- return true
- case (.deletedCell, .deletedGame):
- return true
- default:
- return false
- }
- }
-
- let recordsToSave: [CKRecord] = orderedBatch.compactMap { item in
- switch item.payload.recordType {
- case .game:
- return RecordSerializer.gameRecord(
- from: item.payload,
- zone: zoneID,
- systemFields: item.systemFields
- )
- case .cell:
- return RecordSerializer.cellRecord(
- from: item.payload,
- zone: zoneID,
- systemFields: item.systemFields
- )
- case .deletedGame, .deletedCell:
- return nil
- }
- }
-
- let recordIDsToDelete: [CKRecord.ID] = orderedBatch.compactMap { item in
- switch item.payload.recordType {
- case .deletedGame, .deletedCell:
- return CKRecord.ID(recordName: item.recordName, zoneID: zoneID)
- case .game, .cell:
- return nil
- }
- }
-
- for (i, item) in orderedBatch.enumerated() {
- let kind = item.payload.recordType.rawValue
- let hasSF = item.systemFields != nil ? "yes" : "no"
- await trace("push[\(iteration)]: record[\(i)] \(kind) name=\(item.recordName) systemFields=\(hasSF)")
- }
+ private func trace(_ message: String) async {
+ guard let tracer else { return }
+ await tracer(message)
+ }
- // Push via CKModifyRecordsOperation
- await trace("push[\(iteration)]: calling pushRecords save=\(recordsToSave.count) delete=\(recordIDsToDelete.count)")
- let perRecordResults: [CKRecord.ID: PushResult]
- do {
- perRecordResults = try await pushRecords(
- recordsToSave: recordsToSave,
- recordIDsToDelete: recordIDsToDelete
+ private func saveEngineState(_ serialization: CKSyncEngine.State.Serialization) {
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.userInfo["OutboxRecorder.skip"] = true
+ ctx.performAndWait {
+ guard let data = try? JSONEncoder().encode(serialization) else { return }
+ SyncStateEntity.current(in: ctx).ckEngineState = data
+ try? ctx.save()
+ }
+ }
+
+ /// Looks up the entity for `recordID` and builds the corresponding
+ /// `CKRecord`. Returns `nil` if the record no longer exists in Core Data
+ /// (e.g. deleted between enqueue and send).
+ private nonisolated func buildRecord(
+ for recordID: CKRecord.ID,
+ zoneID: CKRecordZone.ID
+ ) -> CKRecord? {
+ let name = recordID.recordName
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.userInfo["OutboxRecorder.skip"] = true
+ return ctx.performAndWait {
+ if name.hasPrefix("game-") {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else { return nil }
+ return Self.gameRecord(from: entity, zoneID: zoneID)
+ } else if name.hasPrefix("move-") {
+ let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first,
+ let gameID = entity.game?.id
+ else { return nil }
+ let move = Move(
+ gameID: gameID,
+ lamport: entity.lamport,
+ row: Int(entity.row),
+ col: Int(entity.col),
+ letter: entity.letter ?? "",
+ markKind: entity.markKind,
+ checkedWrong: entity.checkedWrong,
+ authorID: entity.authorID,
+ createdAt: entity.createdAt ?? Date()
+ )
+ return RecordSerializer.moveRecord(
+ from: move,
+ zone: zoneID,
+ systemFields: entity.ckSystemFields
+ )
+ } else if name.hasPrefix("snapshot-") {
+ let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first,
+ let gameID = entity.game?.id,
+ let gridData = entity.gridState,
+ let grid = try? MoveLog.decodeGridState(gridData)
+ else { return nil }
+ let snapshot = Snapshot(
+ gameID: gameID,
+ upToLamport: entity.upToLamport,
+ grid: grid,
+ createdAt: entity.createdAt ?? Date()
+ )
+ return try? RecordSerializer.snapshotRecord(
+ from: snapshot,
+ zone: zoneID,
+ systemFields: entity.ckSystemFields
)
- } catch {
- // Operation-level failures (e.g. `.requestRateLimited` or
- // `.zoneBusy` when CloudKit rejects the whole operation
- // rather than reporting per-record results) deserve a
- // `CKErrorRetryAfterKey` sleep before the next batch. Any
- // other error propagates so the caller can surface it.
- if let delay = throttleDelay(for: error) {
- await trace("push[\(iteration)]: operation-level throttle (\(describe(error))), sleeping \(delay)s")
- try await Task.sleep(for: .seconds(delay))
- continue
- }
- await trace("push[\(iteration)]: pushRecords THREW: \(describe(error))")
- throw error
- }
- await trace("push[\(iteration)]: pushRecords returned \(perRecordResults.count) result(s)")
-
- // Process results on the background context. Returns a tuple so
- // we don't have to mutate captured locals from inside the closure.
- let processed: (successes: Int, failures: Int, errors: [String], cellChanges: [RemoteCellChange], retryAfter: TimeInterval?) =
- context.performAndWait {
- var successes = 0
- var failures = 0
- var errors: [String] = []
- var cellChanges: [RemoteCellChange] = []
- var maxRetryAfter: TimeInterval?
-
- for (recordID, result) in perRecordResults {
- let recordName = recordID.recordName
- switch result {
- case .saved(let savedRecord):
- successes += 1
- self.writeBackSystemFields(
- record: savedRecord,
- recordName: recordName,
- in: context
- )
- self.deletePendingChange(recordName: recordName, in: context)
-
- case .deleted:
- successes += 1
- self.deletePendingChange(recordName: recordName, in: context)
-
- case .failed(let error):
- if self.isAlreadyDeleted(error) {
- successes += 1
- self.deletePendingChange(recordName: recordName, in: context)
- continue
- }
- failures += 1
- if let delay = self.throttleDelay(for: error) {
- maxRetryAfter = max(maxRetryAfter ?? 0, delay)
- }
- let changes = self.handlePushError(
- error: error,
- recordName: recordName,
- in: context,
- errorSink: &errors
- )
- cellChanges.append(contentsOf: changes)
- }
- }
- try? context.save()
- return (successes, failures, errors, cellChanges, maxRetryAfter)
- }
-
- let successes = processed.successes
- let failures = processed.failures
- let errorMessages = processed.errors
- let retryAfter = processed.retryAfter
- serverWinsCellChanges.append(contentsOf: processed.cellChanges)
-
- await trace("push[\(iteration)]: processed \(successes) success / \(failures) failure")
- for msg in errorMessages {
- await trace("push[\(iteration)]: \(msg)")
- }
-
- if let retryAfter {
- // Per-record throttle: sleep before the next iteration so we
- // don't hammer CloudKit and tighten the device cooldown.
- // Pending changes stay in the outbox; drain picks them up
- // again after the sleep.
- await trace("push[\(iteration)]: per-record throttle, sleeping \(retryAfter)s before retry")
- try await Task.sleep(for: .seconds(retryAfter))
- continue
- }
-
- if failures > 0 && successes == 0 {
- // Non-throttle all-failure batch: next iteration would pull
- // the same items and loop forever. Break out and surface the
- // situation through the diagnostics view.
- await trace("push[\(iteration)]: all-failure batch, aborting drain to avoid infinite loop")
- break
}
- }
-
- await trace("push: exit")
-
- // Route any server-wins cell changes through the single inbox
- if let onRemoteCellChanges, !serverWinsCellChanges.isEmpty {
- await onRemoteCellChanges(serverWinsCellChanges)
+ return nil
}
}
- // MARK: - Fetch
-
- /// Fetches remote changes using a two-step process:
- /// 1. `CKFetchDatabaseChangesOperation` to discover which zones changed.
- /// 2. `CKFetchRecordZoneChangesOperation` to pull changed records from
- /// our zone.
- /// Applies incoming records to Core Data, persists change tokens, then
- /// hops to MainActor to refresh the in-memory Game.
- func fetchChanges() async throws {
- guard await accountAvailable() else { return }
- try await resolveOwnerIfNeeded()
-
- let context = persistence.container.newBackgroundContext()
- context.userInfo[OutboxRecorder.skipKey] = true
-
- // Step 1: Fetch database-level changes to discover changed zones
- let databaseToken: CKServerChangeToken? = context.performAndWait {
- SyncStateEntity.current(in: context).decodedPrivateDatabaseToken
- }
-
- let changedZoneIDs = try await fetchDatabaseChanges(token: databaseToken, context: context)
- let zoneDescriptions = changedZoneIDs
- .map { "\($0.zoneName)/\(shortOwner($0.ownerName))" }
- .joined(separator: ", ")
- await trace("fetch: changedZoneIDs count=\(changedZoneIDs.count) [\(zoneDescriptions)]")
-
- // Step 2: Fetch zone-level changes.
- //
- // We deliberately do NOT gate this on `changedZoneIDs.contains(zoneID)`.
- // `fetchDatabaseChanges` persists its returned token at the end of
- // every successful call, so if the zone fetch is ever skipped while
- // the database token advances (as happened under the previous
- // owner-mismatch bug), the zone's records become permanently
- // orphaned — the database token is past the change, yet the zone
- // token never advanced because we never ran the zone fetch.
- //
- // Instead, always call `fetchZoneChanges` for our known custom zone
- // and let the zone token be the source of truth for what we've
- // applied. The call is cheap when there are no changes.
- let zoneToken: CKServerChangeToken? = context.performAndWait {
- SyncStateEntity.current(in: context).decodedPrivateZoneToken
+ private static nonisolated func gameRecord(
+ from entity: GameEntity,
+ zoneID: CKRecordZone.ID
+ ) -> CKRecord? {
+ guard let ckName = entity.ckRecordName else { return nil }
+ let recordID = CKRecord.ID(recordName: ckName, zoneID: zoneID)
+ let record: CKRecord
+ if let fields = entity.ckSystemFields,
+ let restored = RecordSerializer.decodeRecord(from: fields) {
+ record = restored
+ } else {
+ record = CKRecord(recordType: "Game", recordID: recordID)
}
-
- let zoneChanges = try await fetchZoneChanges(token: zoneToken, context: context)
- let incomingRecords = zoneChanges.changedRecords
- let deletedRecords = zoneChanges.deletedRecords
- await trace("fetch: incomingRecords count=\(incomingRecords.count)")
- for record in incomingRecords {
- await trace("fetch: record \(record.recordType) name=\(record.recordID.recordName)")
+ record["title"] = entity.title as CKRecordValue?
+ record["completedAt"] = entity.completedAt as CKRecordValue?
+ if let source = entity.puzzleSource {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("xd")
+ try? source.write(to: url, atomically: true, encoding: .utf8)
+ record["puzzleSource"] = CKAsset(fileURL: url)
}
- await trace("fetch: deletedRecords count=\(deletedRecords.count)")
- for deleted in deletedRecords {
- await trace("fetch: deleted \(deleted.recordType ?? "unknown") name=\(deleted.recordID.recordName)")
- }
-
- guard !incomingRecords.isEmpty || !deletedRecords.isEmpty else { return }
+ return record
+ }
- // Extract cell changes before applying (reads directly from CKRecords)
- let cellChanges = incomingRecords.compactMap { Self.extractCellChange(from: $0) }
+ // MARK: - Incoming record application
- // Step 3: Apply incoming records to Core Data
- context.performAndWait {
- self.applyDeletedRecords(deletedRecords, in: context)
- self.applyIncomingRecords(incomingRecords, in: context)
- try? context.save()
+ private nonisolated func applyMoveRecord(
+ _ record: CKRecord,
+ move: Move,
+ in ctx: NSManagedObjectContext
+ ) {
+ let ckName = record.recordID.recordName
+ let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
+ req.fetchLimit = 1
+
+ let entity: MoveEntity
+ if let existing = try? ctx.fetch(req).first {
+ entity = existing
+ } else {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ if let parentRef = record.parent {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ parentRef.recordID.recordName
+ )
+ } else {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ RecordSerializer.recordName(forGameID: move.gameID)
+ )
+ }
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else { return }
+ entity = MoveEntity(context: ctx)
+ entity.game = game
}
- await trace("fetch: applied \(incomingRecords.count) changed and \(deletedRecords.count) deleted record(s) to Core Data")
- // Step 4: Route cell changes through the single inbox
- if let onRemoteCellChanges, !cellChanges.isEmpty {
- await onRemoteCellChanges(cellChanges)
+ entity.ckRecordName = ckName
+ entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
+ entity.lamport = move.lamport
+ entity.row = Int16(move.row)
+ entity.col = Int16(move.col)
+ entity.letter = move.letter
+ entity.markKind = move.markKind
+ entity.checkedWrong = move.checkedWrong
+ entity.authorID = move.authorID
+ entity.createdAt = move.createdAt
+
+ if let game = entity.game, move.lamport > game.lamportHighWater {
+ game.lamportHighWater = move.lamport
}
}
- // MARK: - Fetch helpers
-
- private func fetchDatabaseChanges(
- token: CKServerChangeToken?,
- context: NSManagedObjectContext
- ) async throws -> [CKRecordZone.ID] {
- let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: token)
- operation.qualityOfService = .utility
-
- var changedZoneIDs: [CKRecordZone.ID] = []
-
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecordZone.ID], Error>) in
- operation.recordZoneWithIDChangedBlock = { zoneID in
- changedZoneIDs.append(zoneID)
+ private nonisolated func applySnapshotRecord(
+ _ record: CKRecord,
+ snapshot: Snapshot,
+ in ctx: NSManagedObjectContext
+ ) {
+ let ckName = record.recordID.recordName
+ let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
+ req.fetchLimit = 1
+
+ let entity: SnapshotEntity
+ if let existing = try? ctx.fetch(req).first {
+ entity = existing
+ } else {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ if let parentRef = record.parent {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ parentRef.recordID.recordName
+ )
+ } else {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ RecordSerializer.recordName(forGameID: snapshot.gameID)
+ )
}
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else { return }
+ entity = SnapshotEntity(context: ctx)
+ entity.game = game
+ }
- operation.fetchDatabaseChangesResultBlock = { result in
- switch result {
- case .success(let (serverToken, _)):
- // Persist the database token
- context.performAndWait {
- SyncStateEntity.current(in: context).decodedPrivateDatabaseToken = serverToken
- try? context.save()
- }
- continuation.resume(returning: changedZoneIDs)
- case .failure(let error):
- continuation.resume(throwing: error)
- }
- }
+ entity.ckRecordName = ckName
+ entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
+ entity.upToLamport = snapshot.upToLamport
+ entity.createdAt = snapshot.createdAt
+ entity.gridState = try? MoveLog.encodeGridState(snapshot.grid)
+ }
- privateDatabase.add(operation)
+ /// Replays all persisted moves and snapshots for `gameID` onto the
+ /// `CellEntity` cache. Must be called inside a `performAndWait` block on
+ /// the same context.
+ private nonisolated func replayCellCache(
+ for gameID: UUID,
+ in ctx: NSManagedObjectContext
+ ) {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else { return }
+
+ let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ moveReq.predicate = NSPredicate(format: "game == %@", game)
+ let moveEntities = (try? ctx.fetch(moveReq)) ?? []
+ let moves: [Move] = moveEntities.compactMap { e in
+ guard let gID = e.game?.id else { return nil }
+ return Move(
+ gameID: gID,
+ lamport: e.lamport,
+ row: Int(e.row),
+ col: Int(e.col),
+ letter: e.letter ?? "",
+ markKind: e.markKind,
+ checkedWrong: e.checkedWrong,
+ authorID: e.authorID,
+ createdAt: e.createdAt ?? Date()
+ )
}
- }
- private func fetchZoneChanges(
- token: CKServerChangeToken?,
- context: NSManagedObjectContext
- ) async throws -> (changedRecords: [CKRecord], deletedRecords: [DeletedRecord]) {
- let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
- options.previousServerChangeToken = token
+ let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity")
+ snapReq.predicate = NSPredicate(format: "game == %@", game)
+ let snapEntities = (try? ctx.fetch(snapReq)) ?? []
+ let snapshots: [Snapshot] = snapEntities.compactMap { e in
+ guard let gID = e.game?.id,
+ let data = e.gridState,
+ let grid = try? MoveLog.decodeGridState(data)
+ else { return nil }
+ return Snapshot(
+ gameID: gID,
+ upToLamport: e.upToLamport,
+ grid: grid,
+ createdAt: e.createdAt ?? Date()
+ )
+ }
- let operation = CKFetchRecordZoneChangesOperation(
- recordZoneIDs: [zoneID],
- configurationsByRecordZoneID: [zoneID: options]
+ let gridState = MoveLog.replay(
+ snapshot: MoveLog.latestSnapshot(from: snapshots),
+ moves: moves
)
- operation.qualityOfService = .utility
-
- var incomingRecords: [CKRecord] = []
- var deletedRecords: [DeletedRecord] = []
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(changedRecords: [CKRecord], deletedRecords: [DeletedRecord]), Error>) in
- operation.recordWasChangedBlock = { _, result in
- if case .success(let record) = result {
- incomingRecords.append(record)
- }
- }
+ let existingCells = (game.cells as? Set<CellEntity>) ?? []
+ var byPosition: [GridPosition: CellEntity] = [:]
+ for cell in existingCells {
+ byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
+ }
- operation.recordWithIDWasDeletedBlock = { recordID, recordType in
- deletedRecords.append(DeletedRecord(recordID: recordID, recordType: recordType))
+ for (pos, gridCell) in gridState {
+ let cell: CellEntity
+ if let existing = byPosition[pos] {
+ cell = existing
+ } else {
+ cell = CellEntity(context: ctx)
+ cell.game = game
+ cell.row = Int16(pos.row)
+ cell.col = Int16(pos.col)
}
-
- operation.recordZoneFetchResultBlock = { _, result in
- switch result {
- case .success(let (serverToken, _, _)):
- // Persist the zone token
- context.performAndWait {
- SyncStateEntity.current(in: context).decodedPrivateZoneToken = serverToken
- try? context.save()
+ cell.letter = gridCell.letter
+ cell.markKind = gridCell.markKind
+ cell.checkedWrong = gridCell.checkedWrong
+ cell.letterAuthorID = gridCell.authorID
+ }
+
+ // Clear cells whose position has been wiped from the log.
+ for (pos, cell) in byPosition where gridState[pos] == nil {
+ cell.letter = ""
+ cell.markKind = 0
+ cell.checkedWrong = false
+ cell.letterAuthorID = nil
+ }
+ }
+
+ // MARK: - Event handlers
+
+ private func handleFetchedRecordZoneChanges(
+ _ event: CKSyncEngine.Event.FetchedRecordZoneChanges
+ ) async {
+ await trace("fetch: \(event.modifications.count) modifications, \(event.deletions.count) deletions")
+
+ var newMoves: [Move] = []
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.userInfo["OutboxRecorder.skip"] = true
+
+ ctx.performAndWait {
+ for mod in event.modifications {
+ let record = mod.record
+ switch record.recordType {
+ case "Game":
+ _ = RecordSerializer.applyGameRecord(record, to: ctx)
+ case "Move":
+ if let move = RecordSerializer.parseMoveRecord(record) {
+ self.applyMoveRecord(record, move: move, in: ctx)
+ newMoves.append(move)
+ }
+ case "Snapshot":
+ if let snapshot = RecordSerializer.parseSnapshotRecord(record) {
+ self.applySnapshotRecord(record, snapshot: snapshot, in: ctx)
}
- case .failure(let error):
- print("SyncEngine: zone fetch error: \(error)")
+ default:
+ break
}
}
- operation.fetchRecordZoneChangesResultBlock = { result in
- switch result {
- case .success:
- continuation.resume(returning: (incomingRecords, deletedRecords))
- case .failure(let error):
- continuation.resume(throwing: error)
- }
+ for deletion in event.deletions {
+ self.applyDeletion(
+ recordID: deletion.recordID,
+ recordType: deletion.recordType,
+ in: ctx
+ )
}
- privateDatabase.add(operation)
- }
- }
+ // Replay the full move log for each affected game to keep the
+ // CellEntity cache consistent with the canonical log.
+ let affectedGameIDs = Set(newMoves.map { $0.gameID })
+ for gameID in affectedGameIDs {
+ self.replayCellCache(for: gameID, in: ctx)
+ }
- private nonisolated func applyIncomingRecords(
- _ records: [CKRecord],
- in context: NSManagedObjectContext
- ) {
- // First pass: apply Game records so parent entities exist for cells
- for record in records where record.recordType == "Game" {
- let _ = RecordSerializer.applyGameRecord(record, to: context)
+ if ctx.hasChanges { try? ctx.save() }
}
- // Second pass: apply Cell records
- for record in records where record.recordType == "Cell" {
- guard let parentRef = record.parent else { continue }
- let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName)
- gameRequest.fetchLimit = 1
- guard let gameEntity = try? context.fetch(gameRequest).first else { continue }
- let _ = RecordSerializer.applyCellRecord(record, to: context, game: gameEntity)
+ if let onRemoteMoves, !newMoves.isEmpty {
+ await onRemoteMoves(newMoves)
}
}
- private nonisolated func applyDeletedRecords(
- _ records: [DeletedRecord],
- in context: NSManagedObjectContext
+ private nonisolated func applyDeletion(
+ recordID: CKRecord.ID,
+ recordType: CKRecord.RecordType,
+ in ctx: NSManagedObjectContext
) {
- for deleted in records {
- let recordName = deleted.recordID.recordName
- switch deleted.recordType {
- case "Cell":
- deleteObject(entityName: "CellEntity", recordName: recordName, in: context)
- case "Game":
- deleteObject(entityName: "GameEntity", recordName: recordName, in: context)
- default:
- if recordName.hasPrefix("cell-") {
- deleteObject(entityName: "CellEntity", recordName: recordName, in: context)
- } else if recordName.hasPrefix("game-") {
- deleteObject(entityName: "GameEntity", recordName: recordName, in: context)
- }
+ let name = recordID.recordName
+ let entityName: String
+ if name.hasPrefix("move-") {
+ entityName = "MoveEntity"
+ } else if name.hasPrefix("snapshot-") {
+ entityName = "SnapshotEntity"
+ } else if name.hasPrefix("game-") {
+ entityName = "GameEntity"
+ } else {
+ switch recordType {
+ case "Move": entityName = "MoveEntity"
+ case "Snapshot": entityName = "SnapshotEntity"
+ case "Game": entityName = "GameEntity"
+ default: return
}
}
- }
-
- private nonisolated func deleteObject(
- entityName: String,
- recordName: String,
- in context: NSManagedObjectContext
- ) {
- let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
- request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
- request.fetchLimit = 1
- if let object = try? context.fetch(request).first {
- context.delete(object)
+ let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ if let obj = try? ctx.fetch(req).first {
+ ctx.delete(obj)
}
}
- // MARK: - Push helpers
-
- private func pushRecords(
- recordsToSave: [CKRecord],
- recordIDsToDelete: [CKRecord.ID]
- ) async throws -> [CKRecord.ID: PushResult] {
- let operation = CKModifyRecordsOperation(
- recordsToSave: recordsToSave,
- recordIDsToDelete: recordIDsToDelete
- )
- operation.savePolicy = .changedKeys
- operation.isAtomic = false
- operation.qualityOfService = .utility
-
- var perRecordResults: [CKRecord.ID: PushResult] = [:]
-
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord.ID: PushResult], Error>) in
- operation.perRecordSaveBlock = { recordID, result in
- switch result {
- case .success(let record):
- perRecordResults[recordID] = .saved(record)
- case .failure(let error):
- perRecordResults[recordID] = .failed(error)
- }
- }
-
- operation.perRecordDeleteBlock = { recordID, result in
- switch result {
- case .success:
- perRecordResults[recordID] = .deleted
- case .failure(let error):
- perRecordResults[recordID] = .failed(error)
- }
+ private func handleSentRecordZoneChanges(
+ _ event: CKSyncEngine.Event.SentRecordZoneChanges
+ ) {
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.userInfo["OutboxRecorder.skip"] = true
+ ctx.performAndWait {
+ for record in event.savedRecords {
+ self.writeBackSystemFields(record: record, in: ctx)
}
-
- operation.modifyRecordsResultBlock = { result in
- switch result {
- case .success:
- continuation.resume(returning: perRecordResults)
- case .failure(let error):
- // If the overall operation failed but we got per-record
- // results, return those so we can handle partial success.
- if !perRecordResults.isEmpty {
- continuation.resume(returning: perRecordResults)
- } else {
- continuation.resume(throwing: error)
- }
- }
+ for failure in event.failedRecordSaves {
+ self.trace(
+ "send: failed to save \(failure.record.recordID.recordName): \(failure.error.localizedDescription)"
+ )
}
-
- privateDatabase.add(operation)
+ if ctx.hasChanges { try? ctx.save() }
}
}
- private nonisolated func lookupSystemFields(
- recordName: String,
- recordType: String,
- in context: NSManagedObjectContext
- ) -> Data? {
- let entityName = recordType == "Game" ? "GameEntity" : "CellEntity"
- let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
- request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
- request.fetchLimit = 1
- request.propertiesToFetch = ["ckSystemFields"]
- guard let entity = try? context.fetch(request).first else { return nil }
- return entity.value(forKey: "ckSystemFields") as? Data
- }
-
private nonisolated func writeBackSystemFields(
record: CKRecord,
- recordName: String,
- in context: NSManagedObjectContext
+ in ctx: NSManagedObjectContext
) {
- let entityName = record.recordType == "Game" ? "GameEntity" : "CellEntity"
- let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
- request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
- request.fetchLimit = 1
- guard let entity = try? context.fetch(request).first else { return }
-
- entity.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
- entity.setValue(Date(), forKey: "lastSyncedAt")
- }
+ let name = record.recordID.recordName
+ let entityName: String
+ if name.hasPrefix("move-") { entityName = "MoveEntity" }
+ else if name.hasPrefix("snapshot-") { entityName = "SnapshotEntity" }
+ else if name.hasPrefix("game-") { entityName = "GameEntity" }
+ else { return }
- private nonisolated func deletePendingChange(recordName: String, in context: NSManagedObjectContext) {
- let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- request.predicate = NSPredicate(format: "recordName == %@", recordName)
- request.fetchLimit = 1
- if let entity = try? context.fetch(request).first {
- context.delete(entity)
+ let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let obj = try? ctx.fetch(req).first else { return }
+ obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
+ if entityName == "GameEntity" {
+ obj.setValue(Date(), forKey: "lastSyncedAt")
}
}
- private nonisolated func isAlreadyDeleted(_ error: Error) -> Bool {
- guard let ckError = error as? CKError else { return false }
- return ckError.code == .unknownItem
- }
-
- /// Returns the number of seconds to wait before retrying a throttled
- /// operation, or `nil` if the error is not a throttle. Prefers the
- /// server-supplied `CKErrorRetryAfterKey` and falls back to a modest
- /// default when CloudKit omitted the hint.
- private nonisolated func throttleDelay(for error: Error) -> TimeInterval? {
- guard let ckError = error as? CKError else { return nil }
- switch ckError.code {
- case .serviceUnavailable, .requestRateLimited, .zoneBusy:
- break
- default:
- return nil
- }
- if let seconds = ckError.userInfo[CKErrorRetryAfterKey] as? TimeInterval {
- return seconds
- }
- if let number = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber {
- return number.doubleValue
- }
- return 5
- }
-
- /// Returns any `RemoteCellChange` values that resulted from the server
- /// winning a conflict (so the caller can route them through the single
- /// inbox on the main actor).
- private nonisolated func handlePushError(
- error: Error,
- recordName: String,
- in context: NSManagedObjectContext,
- errorSink: inout [String]
- ) -> [RemoteCellChange] {
- guard let ckError = error as? CKError else {
- let nsError = error as NSError
- errorSink.append("\(recordName): non-CK error domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)")
- return []
- }
-
- switch ckError.code {
- case .serverRecordChanged:
- // The server has a newer version. Extract the server record and
- // merge using LWW on our `updatedAt` field.
- guard let serverRecord = ckError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else {
- print("SyncEngine: serverRecordChanged but no server record for \(recordName)")
- return []
- }
-
- // Read the pending change to get our local values
- let pendingRequest = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- pendingRequest.predicate = NSPredicate(format: "recordName == %@", recordName)
- pendingRequest.fetchLimit = 1
- guard let pending = try? context.fetch(pendingRequest).first,
- let payloadJSON = pending.payload,
- let data = payloadJSON.data(using: .utf8),
- let localPayload = try? JSONDecoder().decode(PendingChangePayload.self, from: data)
- else { return [] }
-
- let serverUpdatedAt = serverRecord["updatedAt"] as? Date ?? .distantPast
- let localUpdatedAt = localPayload.updatedAt ?? .distantPast
-
- if localUpdatedAt >= serverUpdatedAt {
- // Local wins — re-enqueue with the server record's system
- // fields so the next push succeeds.
- let systemFields = RecordSerializer.encodeSystemFields(of: serverRecord)
- writeBackSystemFields(record: serverRecord, recordName: recordName, in: context)
-
- // The pending change stays in the outbox; next loop iteration
- // will pick it up with the updated system fields.
- _ = systemFields
- return []
- } else {
- // Server wins — drop our pending change and apply the server
- // record to Core Data.
- context.delete(pending)
- if serverRecord.recordType == "Game" {
- let _ = RecordSerializer.applyGameRecord(serverRecord, to: context)
- return []
- } else {
- // Find the parent game for the cell
- if let parentRef = serverRecord.parent {
- let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName)
- gameRequest.fetchLimit = 1
- if let gameEntity = try? context.fetch(gameRequest).first {
- let _ = RecordSerializer.applyCellRecord(serverRecord, to: context, game: gameEntity)
- }
- }
- if let change = Self.extractCellChange(from: serverRecord) {
- return [change]
- }
- return []
- }
- }
+ // MARK: - Logging helpers
- default:
- // For other errors (network, throttle, etc.), leave the pending
- // change in the outbox for the next push attempt.
- let userInfo = ckError.userInfo
- .map { "\($0.key)=\($0.value)" }
- .joined(separator: " | ")
- errorSink.append("\(recordName): CKError code=\(ckError.code.rawValue) \(ckError.localizedDescription) | \(userInfo)")
- return []
- }
+ private nonisolated func trace(_ message: String) {
+ // nonisolated variant for use inside performAndWait; actor-isolated
+ // version is above. Print to console so traces are never lost even
+ // when the tracer isn't wired up yet.
+ print("SyncEngine: \(message)")
}
- // MARK: - Remote cell change extraction
-
- /// Decodes the cell-relevant fields from a `CKRecord` into a value type
- /// that can be sent to the main actor.
- private static func extractCellChange(from record: CKRecord) -> RemoteCellChange? {
- guard record.recordType == "Cell" else { return nil }
- guard let parentRef = record.parent else { return nil }
-
- // Parse row/col from record name (format: "cell-<uuid>-<row>-<col>")
- let parts = record.recordID.recordName.split(separator: "-")
- guard parts.count >= 2,
- let row = Int(parts[parts.count - 2]),
- let col = Int(parts[parts.count - 1])
- else { return nil }
-
- return RemoteCellChange(
- gameRecordName: parentRef.recordID.recordName,
- row: row,
- col: col,
- letter: record["letter"] as? String ?? "",
- markKind: record["markKind"] as? Int16 ?? 0,
- checkedWrong: record["checkedWrong"] as? Bool ?? false,
- updatedAt: record["updatedAt"] as? Date,
- letterAuthorID: record["letterAuthorID"] as? String
- )
+ private nonisolated func describe(_ error: Error) -> String {
+ let nsError = error as NSError
+ return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
}
- // MARK: - Diagnostics
-
- struct DiagnosticSnapshot: Sendable {
- let accountStatus: CKAccountStatus
- let zoneCreated: Bool
- let subscriptionCreated: Bool
- let outboxCount: Int
- let hasDatabaseToken: Bool
- let hasZoneToken: Bool
+ private nonisolated func describeStatus(_ status: CKAccountStatus) -> String {
+ switch status {
+ case .available: return "available"
+ case .noAccount: return "noAccount"
+ case .restricted: return "restricted"
+ case .couldNotDetermine: return "couldNotDetermine"
+ case .temporarilyUnavailable: return "temporarilyUnavailable"
+ @unknown default: return "unknown(\(status.rawValue))"
+ }
}
+}
- /// Runs a sequence of minimal CloudKit operations against the container
- /// and returns a list of (probe name, result) pairs for diagnostic display.
- /// Each probe runs independently — a failure in one does not stop others.
- func probeContainer() async -> [(name: String, result: String)] {
- var results: [(String, String)] = []
+// MARK: - CKSyncEngineDelegate
- results.append(("containerIdentifier", container.containerIdentifier ?? "nil"))
+extension SyncEngine: CKSyncEngineDelegate {
+ func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
+ switch event {
+ case .stateUpdate(let e):
+ saveEngineState(e.stateSerialization)
- do {
- let status = try await container.accountStatus()
- results.append(("accountStatus", String(describing: status)))
- } catch {
- results.append(("accountStatus", describe(error)))
- }
+ case .accountChange(let e):
+ await trace("account change: \(e.changeType)")
- do {
- let recordID = try await container.userRecordID()
- results.append(("userRecordID", recordID.recordName))
- } catch {
- results.append(("userRecordID", describe(error)))
- }
+ case .fetchedDatabaseChanges(let e):
+ await trace(
+ "fetched database changes: \(e.modifications.count) zone modifications, " +
+ "\(e.deletions.count) zone deletions"
+ )
- do {
- let zones = try await privateDatabase.allRecordZones()
- let zoneNames = zones.map(\.zoneID.zoneName).joined(separator: ", ")
- results.append(("allRecordZones", "\(zones.count) zone(s): [\(zoneNames)]"))
- } catch {
- results.append(("allRecordZones", describe(error)))
- }
+ case .fetchedRecordZoneChanges(let e):
+ await handleFetchedRecordZoneChanges(e)
- // Real write probe: push a throwaway Game record to the default zone via
- // CKModifyRecordsOperation. Unlike CKModifyRecordZonesOperation (which
- // can report phantom success), this hits the real record-write path and
- // surfaces whatever the server actually returns.
- let probeRecordID = CKRecord.ID(
- recordName: "probe-\(UUID().uuidString)",
- zoneID: CKRecordZone.ID(zoneName: CKRecordZone.ID.defaultZoneName, ownerName: CKCurrentUserDefaultName)
- )
- let probeRecord = CKRecord(recordType: "Game", recordID: probeRecordID)
- probeRecord["title"] = "probe" as NSString
-
- let writeOp = CKModifyRecordsOperation(recordsToSave: [probeRecord], recordIDsToDelete: nil)
- writeOp.savePolicy = .allKeys
- writeOp.isAtomic = false
- writeOp.qualityOfService = .userInitiated
-
- let writeResult: String = await withCheckedContinuation { continuation in
- var perRecord: [(CKRecord.ID, Result<CKRecord, Error>)] = []
- writeOp.perRecordSaveBlock = { id, result in
- perRecord.append((id, result))
- }
- writeOp.modifyRecordsResultBlock = { result in
- switch result {
- case .success:
- if let first = perRecord.first {
- switch first.1 {
- case .success(let saved):
- continuation.resume(returning: "saved \(saved.recordID.recordName) etag=\(saved.recordChangeTag ?? "nil")")
- case .failure(let err):
- continuation.resume(returning: "perRecord FAILURE: \(self.describe(err))")
- }
- } else {
- continuation.resume(returning: "operation success but no per-record result")
- }
- case .failure(let err):
- continuation.resume(returning: "operation FAILURE: \(self.describe(err))")
- }
- }
- privateDatabase.add(writeOp)
- }
- results.append(("writeProbe(default zone)", writeResult))
+ case .sentDatabaseChanges:
+ break
- return results
- }
+ case .sentRecordZoneChanges(let e):
+ handleSentRecordZoneChanges(e)
- private func describe(_ error: Error) -> String {
- let nsError = error as NSError
- return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
- }
+ case .willFetchChanges, .didFetchChanges,
+ .willSendChanges, .didSendChanges:
+ break
- /// Clears the local sync bookkeeping (zone/subscription created flags and
- /// change tokens) so the next bootstrap actually hits the network. Used by
- /// the diagnostics screen to recover from stale flags. Does not touch the
- /// outbox or any game data.
- func resetSyncState() async {
- let context = persistence.container.newBackgroundContext()
- context.userInfo[OutboxRecorder.skipKey] = true
- context.performAndWait {
- let state = SyncStateEntity.current(in: context)
- state.zoneCreated = false
- state.subscriptionCreated = false
- state.privateDatabaseToken = nil
- state.privateZoneToken = nil
- try? context.save()
+ @unknown default:
+ break
}
}
- func diagnosticSnapshot() async -> DiagnosticSnapshot {
- let accountStatus: CKAccountStatus
- do {
- accountStatus = try await container.accountStatus()
- } catch {
- accountStatus = .couldNotDetermine
- }
-
- let context = persistence.container.newBackgroundContext()
- context.userInfo[OutboxRecorder.skipKey] = true
- return context.performAndWait {
- let state = SyncStateEntity.current(in: context)
- let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
- let outboxCount = (try? context.count(for: request)) ?? 0
- return DiagnosticSnapshot(
- accountStatus: accountStatus,
- zoneCreated: state.zoneCreated,
- subscriptionCreated: state.subscriptionCreated,
- outboxCount: outboxCount,
- hasDatabaseToken: state.privateDatabaseToken != nil,
- hasZoneToken: state.privateZoneToken != nil
- )
+ func nextRecordZoneChangeBatch(
+ _ context: CKSyncEngine.SendChangesContext,
+ syncEngine: CKSyncEngine
+ ) async -> CKSyncEngine.RecordZoneChangeBatch? {
+ let zoneID = RecordSerializer.zoneID()
+ let pending = syncEngine.state.pendingRecordZoneChanges
+ guard !pending.isEmpty else { return nil }
+ return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
+ guard let self else { return nil }
+ return self.buildRecord(for: recordID, zoneID: zoneID)
}
}
}
diff --git a/Crossmate/Sync/SyncState+Helpers.swift b/Crossmate/Sync/SyncState+Helpers.swift
@@ -18,23 +18,4 @@ extension SyncStateEntity {
return entity
}
- // MARK: - Change token coding
-
- var decodedPrivateDatabaseToken: CKServerChangeToken? {
- get { privateDatabaseToken.flatMap(Self.decodeToken) }
- set { privateDatabaseToken = newValue.flatMap(Self.encodeToken) }
- }
-
- var decodedPrivateZoneToken: CKServerChangeToken? {
- get { privateZoneToken.flatMap(Self.decodeToken) }
- set { privateZoneToken = newValue.flatMap(Self.encodeToken) }
- }
-
- private static func encodeToken(_ token: CKServerChangeToken) -> Data? {
- try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
- }
-
- private static func decodeToken(_ data: Data) -> CKServerChangeToken? {
- try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data)
- }
}
diff --git a/Crossmate/Views/SyncDiagnosticsView.swift b/Crossmate/Views/SyncDiagnosticsView.swift
@@ -18,11 +18,8 @@ struct SyncDiagnosticsView: View {
List {
Section("Status") {
row("Account Status", accountStatusText)
- row("Zone Created", boolText(syncMonitor.snapshot?.zoneCreated))
- row("Subscription Created", boolText(syncMonitor.snapshot?.subscriptionCreated))
- row("Pending Outbox", syncMonitor.snapshot.map { String($0.outboxCount) } ?? "Unknown")
- row("Database Token", boolText(syncMonitor.snapshot?.hasDatabaseToken))
- row("Zone Token", boolText(syncMonitor.snapshot?.hasZoneToken))
+ row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
+ row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
row(
"Last Success",
syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None"
@@ -124,9 +121,6 @@ struct SyncDiagnosticsView: View {
isSyncing = true
defer { isSyncing = false }
- await AppServices.run("manual bootstrap", monitor: syncMonitor) {
- try await syncEngine.bootstrap()
- }
await AppServices.run("manual fetch", monitor: syncMonitor) {
try await syncEngine.fetchChanges()
}
@@ -172,11 +166,8 @@ struct SyncDiagnosticsView: View {
private var diagnosticDump: String {
var lines: [String] = []
lines.append("Account Status: \(accountStatusText)")
- lines.append("Zone Created: \(boolText(syncMonitor.snapshot?.zoneCreated))")
- lines.append("Subscription Created: \(boolText(syncMonitor.snapshot?.subscriptionCreated))")
- lines.append("Pending Outbox: \(syncMonitor.snapshot.map { String($0.outboxCount) } ?? "Unknown")")
- lines.append("Database Token: \(boolText(syncMonitor.snapshot?.hasDatabaseToken))")
- lines.append("Zone Token: \(boolText(syncMonitor.snapshot?.hasZoneToken))")
+ lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
+ lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
lines.append(
"Last Success: \(syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None")"
)
diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift
@@ -11,6 +11,7 @@ func makeTestPersistence() -> PersistenceController {
/// Creates a Game, GameEntity, and GameMutator backed by an in-memory store.
/// The puzzle is a minimal 3x3 grid with a single block at (1,1).
+/// `moveBuffer` is nil — tests that need emission verify via MoveBuffer's own suite.
@MainActor
func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceController) {
let persistence = makeTestPersistence()
@@ -47,21 +48,8 @@ func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceControl
entity.updatedAt = Date()
entity.ckRecordName = "game-\(gameID.uuidString)"
- for row in puzzle.cells {
- for cell in row where !cell.isBlock {
- let cellEntity = CellEntity(context: context)
- cellEntity.row = Int16(cell.row)
- cellEntity.col = Int16(cell.col)
- cellEntity.letter = ""
- cellEntity.markKind = 0
- cellEntity.checkedWrong = false
- cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
- cellEntity.game = entity
- }
- }
-
try context.save()
- let mutator = GameMutator(game: game, gameEntity: entity, context: context)
+ let mutator = GameMutator(game: game, gameID: gameID, moveBuffer: nil)
return (game, mutator, entity, persistence)
}
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -14,18 +14,17 @@ struct GameMutatorTests {
func setLetterWritesToGame() throws {
let (game, mutator, _, _) = try makeTestGame()
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
#expect(game.squares[0][0].entry == "A")
#expect(game.squares[0][0].mark == .none)
- #expect(game.squares[0][0].updatedAt != nil)
}
@Test("setLetter in pencil mode sets pencil mark")
func setLetterPencilMode() throws {
let (game, mutator, _, _) = try makeTestGame()
- mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true, origin: .local)
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true)
#expect(game.squares[0][1].entry == "B")
#expect(game.squares[0][1].mark == .pencil(checkedWrong: false))
@@ -35,148 +34,14 @@ struct GameMutatorTests {
func clearLetterClearsEntry() throws {
let (game, mutator, _, _) = try makeTestGame()
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
- mutator.clearLetter(atRow: 0, atCol: 0, origin: .local)
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.clearLetter(atRow: 0, atCol: 0)
#expect(game.squares[0][0].entry == "")
#expect(game.squares[0][0].mark == .none)
#expect(game.squares[0][0].letterAuthorID == nil)
}
- // MARK: - LWW
-
- @Test("Remote mutation with newer timestamp overwrites local")
- func remoteNewerOverwritesLocal() throws {
- let (game, mutator, _, _) = try makeTestGame()
-
- let earlier = Date(timeIntervalSinceNow: -10)
- let later = Date(timeIntervalSinceNow: 10)
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: earlier, authorID: "user1"))
- mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: later, authorID: "user2"))
-
- #expect(game.squares[0][0].entry == "B")
- #expect(game.squares[0][0].letterAuthorID == "user2")
- }
-
- @Test("Remote mutation with older timestamp is rejected")
- func remoteOlderIsRejected() throws {
- let (game, mutator, _, _) = try makeTestGame()
-
- let earlier = Date(timeIntervalSinceNow: -10)
- let later = Date(timeIntervalSinceNow: 10)
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: later, authorID: "user1"))
- mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: earlier, authorID: "user2"))
-
- #expect(game.squares[0][0].entry == "A")
- #expect(game.squares[0][0].letterAuthorID == "user1")
- }
-
- @Test("Remote mutation with equal timestamp is accepted")
- func remoteEqualTimestampAccepted() throws {
- let (game, mutator, _, _) = try makeTestGame()
-
- let t = Date()
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: t, authorID: "user1"))
- mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: t, authorID: "user2"))
-
- #expect(game.squares[0][0].entry == "B")
- }
-
- // MARK: - Persistence
-
- @Test("setLetter persists to CellEntity")
- func setLetterPersistsToCellEntity() throws {
- let (_, mutator, entity, persistence) = try makeTestGame()
- let context = persistence.viewContext
-
- mutator.setLetter("X", atRow: 0, atCol: 2, pencil: false, origin: .local)
-
- let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
- let cell = cellEntities.first { $0.row == 0 && $0.col == 2 }
-
- #expect(cell?.letter == "X")
- #expect(cell?.updatedAt != nil)
- }
-
- // MARK: - PendingChange outbox
-
- @Test("Local mutation enqueues a PendingChange")
- func localMutationEnqueuesPendingChange() throws {
- let (_, mutator, _, persistence) = try makeTestGame()
- let context = persistence.viewContext
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
-
- let pending = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(pending.count == 1)
- #expect(pending[0].recordType == "Cell")
- #expect(pending[0].recordName?.contains("cell-") == true)
- }
-
- @Test("Remote mutation does not enqueue a PendingChange")
- func remoteMutationDoesNotEnqueue() throws {
- let (_, mutator, _, persistence) = try makeTestGame()
- let context = persistence.viewContext
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: Date(), authorID: "user1"))
-
- let pending = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(pending.isEmpty)
- }
-
- @Test("Multiple local mutations to same cell coalesce into one PendingChange")
- func localMutationsCoalesce() throws {
- let (_, mutator, _, persistence) = try makeTestGame()
- let context = persistence.viewContext
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
- mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, origin: .local)
- mutator.setLetter("C", atRow: 0, atCol: 0, pencil: false, origin: .local)
-
- let pending = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(pending.count == 1)
-
- // The payload should reflect the latest value
- let data = pending[0].payload!.data(using: .utf8)!
- let payload = try JSONDecoder().decode(PendingChangePayload.self, from: data)
- #expect(payload.letter == "C")
- }
-
- // MARK: - onLocalMutation callback
-
- @Test("onLocalMutation fires on local mutation")
- func onLocalMutationFires() throws {
- let (_, mutator, _, _) = try makeTestGame()
- var callCount = 0
- mutator.onLocalMutation = { callCount += 1 }
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
-
- #expect(callCount == 1)
- }
-
- @Test("onLocalMutation does not fire on remote mutation")
- func onLocalMutationSilentForRemote() throws {
- let (_, mutator, _, _) = try makeTestGame()
- var callCount = 0
- mutator.onLocalMutation = { callCount += 1 }
-
- mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
- origin: .remote(timestamp: Date(), authorID: "user1"))
-
- #expect(callCount == 0)
- }
-
// MARK: - Bulk mutations
@Test("checkCells marks wrong entries via mutator")
@@ -184,8 +49,8 @@ struct GameMutatorTests {
let (game, mutator, _, _) = try makeTestGame()
// Cell (0,0) has solution "A", enter "Z"
- mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false, origin: .local)
- mutator.checkCells([game.puzzle.cells[0][0]], origin: .local)
+ mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
+ mutator.checkCells([game.puzzle.cells[0][0]])
#expect(game.squares[0][0].mark == .pen(checkedWrong: true))
}
@@ -194,7 +59,7 @@ struct GameMutatorTests {
func revealCellsSetsAnswer() throws {
let (game, mutator, _, _) = try makeTestGame()
- mutator.revealCells([game.puzzle.cells[0][0]], origin: .local)
+ mutator.revealCells([game.puzzle.cells[0][0]])
#expect(game.squares[0][0].entry == "A")
#expect(game.squares[0][0].mark == .revealed)
@@ -204,8 +69,8 @@ struct GameMutatorTests {
func clearCellsClearsNonRevealed() throws {
let (game, mutator, _, _) = try makeTestGame()
- mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false, origin: .local)
- mutator.clearCells([game.puzzle.cells[0][0]], origin: .local)
+ mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false)
+ mutator.clearCells([game.puzzle.cells[0][0]])
#expect(game.squares[0][0].entry == "")
#expect(game.squares[0][0].mark == .none)
diff --git a/Tests/Unit/MoveBufferTests.swift b/Tests/Unit/MoveBufferTests.swift
@@ -0,0 +1,243 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("MoveBuffer", .serialized)
+@MainActor
+struct MoveBufferTests {
+
+ /// Thread-safe collector for moves emitted by the buffer under test.
+ actor Capture {
+ private(set) var flushes: [[Move]] = []
+ var allMoves: [Move] { flushes.flatMap { $0 } }
+ var flushCount: Int { flushes.count }
+ func append(_ moves: [Move]) { flushes.append(moves) }
+ }
+
+ /// Builds a `PersistenceController` backed by an in-memory store and
+ /// seeds a single `GameEntity`, returning its id so tests can target it.
+ private func makePersistenceWithGame(
+ lamportHighWater: Int64 = 0
+ ) throws -> (PersistenceController, UUID) {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+ let gameID = UUID()
+ let entity = GameEntity(context: context)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.lamportHighWater = lamportHighWater
+ try context.save()
+ return (persistence, gameID)
+ }
+
+ @Test("Same-cell enqueues coalesce to one move carrying the latest value")
+ func coalescesSameCell() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.flush()
+
+ let moves = await capture.allMoves
+ #expect(moves.count == 1)
+ #expect(moves.first?.letter == "C")
+ #expect(moves.first?.lamport == 1)
+ }
+
+ @Test("Enqueuing a different cell flushes the previous cell first")
+ func cellChangeFlushesPrevious() async throws {
+ // A long debounce makes this test insensitive to timer jitter:
+ // only the cell-change trigger (and the final explicit flush) can
+ // fire.
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.flush()
+
+ let flushes = await capture.flushes
+ #expect(flushes.count == 2)
+ #expect(flushes.first?.first?.letter == "A")
+ #expect(flushes.first?.first?.lamport == 1)
+ #expect(flushes.last?.first?.letter == "B")
+ #expect(flushes.last?.first?.lamport == 2)
+ }
+
+ @Test("Lamports are allocated from GameEntity.lamportHighWater and bump it")
+ func lamportsUseGameHighWater() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame(lamportHighWater: 10)
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.flush()
+
+ let moves = await capture.allMoves
+ #expect(moves.first?.lamport == 11)
+
+ // Verify the bump landed in Core Data by reading the game back from
+ // a fresh context — the writer used a background context, so we
+ // need to read from the same underlying store.
+ let highWater = fetchHighWater(gameID: gameID, persistence: persistence)
+ #expect(highWater == 11)
+ }
+
+ @Test("Debounce coalesces rapid same-cell enqueues into one flush")
+ func debounceCoalescesRapidEnqueues() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .milliseconds(80),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ for letter in ["A", "B", "C", "D", "E"] {
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: letter, markKind: 0, checkedWrong: false, authorID: nil)
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try await Task.sleep(for: .milliseconds(250))
+
+ let count = await capture.flushCount
+ #expect(count == 1)
+ let moves = await capture.allMoves
+ #expect(moves.first?.letter == "E")
+ }
+
+ @Test("Explicit flush fires immediately and cancels any pending debounce")
+ func flushCancelsDebounce() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(5),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
+ await buffer.flush()
+ let afterFlush = await capture.flushCount
+ #expect(afterFlush == 1)
+
+ // The debounce should have been cancelled by flush, so waiting past
+ // the original interval must not add a second call.
+ try await Task.sleep(for: .milliseconds(200))
+ let later = await capture.flushCount
+ #expect(later == 1)
+ }
+
+ @Test("Flush on an empty buffer does not invoke the sink")
+ func emptyFlushDoesNothing() async throws {
+ let (persistence, _) = try makePersistenceWithGame()
+ let capture = Capture()
+ let buffer = MoveBuffer(
+ debounceInterval: .milliseconds(50),
+ persistence: persistence,
+ sink: { await capture.append($0) }
+ )
+
+ await buffer.flush()
+
+ let count = await capture.flushCount
+ #expect(count == 0)
+ }
+
+ @Test("MoveEntity rows are written with the enqueued fields")
+ func persistsMoveEntity() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { _ in }
+ )
+
+ await buffer.enqueue(
+ gameID: gameID, row: 2, col: 3,
+ letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice"
+ )
+ await buffer.flush()
+
+ let moves = fetchMoveValues(gameID: gameID, persistence: persistence)
+ #expect(moves.count == 1)
+ let m = moves.first
+ #expect(m?.letter == "Q")
+ #expect(m?.row == 2)
+ #expect(m?.col == 3)
+ #expect(m?.markKind == 1)
+ #expect(m?.checkedWrong == true)
+ #expect(m?.authorID == "alice")
+ #expect(m?.lamport == 1)
+ #expect(m?.ckRecordName == "move-\(gameID.uuidString)-1")
+ }
+
+ // MARK: - Helpers
+
+ /// Reads the game's lamport high-water from a fresh background context.
+ private func fetchHighWater(gameID: UUID, persistence: PersistenceController) -> Int64 {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ return (try? context.fetch(request).first?.lamportHighWater) ?? 0
+ }
+ }
+
+ /// Extracts `MoveEntity` field values inside the background context so
+ /// no `NSManagedObject` escapes its owning context.
+ struct MoveValues {
+ let letter: String
+ let row: Int16
+ let col: Int16
+ let markKind: Int16
+ let checkedWrong: Bool
+ let authorID: String?
+ let lamport: Int64
+ let ckRecordName: String?
+ }
+
+ private func fetchMoveValues(gameID: UUID, persistence: PersistenceController) -> [MoveValues] {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)]
+ guard let entities = try? context.fetch(request) else { return [] }
+ return entities.map {
+ MoveValues(
+ letter: $0.letter ?? "",
+ row: $0.row,
+ col: $0.col,
+ markKind: $0.markKind,
+ checkedWrong: $0.checkedWrong,
+ authorID: $0.authorID,
+ lamport: $0.lamport,
+ ckRecordName: $0.ckRecordName
+ )
+ }
+ }
+ }
+}
diff --git a/Tests/Unit/PendingChangeTests.swift b/Tests/Unit/PendingChangeTests.swift
@@ -1,133 +0,0 @@
-import CoreData
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-@Suite("PendingChange+Helpers", .serialized)
-@MainActor
-struct PendingChangeTests {
-
- @Test("Upsert creates a new row")
- func upsertCreatesNew() {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- PendingChangeEntity.upsert(
- recordName: "cell-1",
- recordType: "Cell",
- payload: "{\"letter\":\"A\"}",
- in: context
- )
- try? context.save()
-
- let results = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(results.count == 1)
- #expect(results[0].recordName == "cell-1")
- #expect(results[0].payload == "{\"letter\":\"A\"}")
- }
-
- @Test("Upsert with same recordName updates existing row")
- func upsertUpdatesExisting() {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- PendingChangeEntity.upsert(
- recordName: "cell-1",
- recordType: "Cell",
- payload: "{\"letter\":\"A\"}",
- in: context
- )
- try? context.save()
-
- PendingChangeEntity.upsert(
- recordName: "cell-1",
- recordType: "Cell",
- payload: "{\"letter\":\"B\"}",
- in: context
- )
- try? context.save()
-
- let results = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(results.count == 1)
- #expect(results[0].payload == "{\"letter\":\"B\"}")
- }
-
- @Test("Upsert with different recordNames creates separate rows")
- func upsertDifferentNames() {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- PendingChangeEntity.upsert(
- recordName: "cell-1",
- recordType: "Cell",
- payload: "{}",
- in: context
- )
- PendingChangeEntity.upsert(
- recordName: "cell-2",
- recordType: "Cell",
- payload: "{}",
- in: context
- )
- try? context.save()
-
- let results = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(results.count == 2)
- }
-
- @Test("Drain returns oldest first")
- func drainReturnsOldestFirst() throws {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- PendingChangeEntity.upsert(
- recordName: "cell-old",
- recordType: "Cell",
- payload: "{}",
- in: context
- )
- // Ensure different timestamps
- Thread.sleep(forTimeInterval: 0.01)
- PendingChangeEntity.upsert(
- recordName: "cell-new",
- recordType: "Cell",
- payload: "{}",
- in: context
- )
- try context.save()
-
- let results = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(results.count == 2)
- #expect(results[0].recordName == "cell-old")
- #expect(results[1].recordName == "cell-new")
- }
-
- @Test("Drain respects limit")
- func drainRespectsLimit() {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- for i in 0..<5 {
- PendingChangeEntity.upsert(
- recordName: "cell-\(i)",
- recordType: "Cell",
- payload: "{}",
- in: context
- )
- }
- try? context.save()
-
- let results = PendingChangeEntity.drain(limit: 3, in: context)
- #expect(results.count == 3)
- }
-
- @Test("Drain returns empty array when no pending changes")
- func drainEmptyWhenNone() {
- let persistence = makeTestPersistence()
- let context = persistence.viewContext
-
- let results = PendingChangeEntity.drain(limit: 10, in: context)
- #expect(results.isEmpty)
- }
-}
diff --git a/Tests/Unit/PushDebouncerTests.swift b/Tests/Unit/PushDebouncerTests.swift
@@ -1,68 +0,0 @@
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-@Suite("PushDebouncer", .serialized)
-struct PushDebouncerTests {
-
- /// Thread-safe counter for actions observed by the debouncer under test.
- actor Counter {
- private(set) var count = 0
- func increment() { count += 1 }
- }
-
- @Test("Multiple schedules within the window collapse to one call")
- func coalescesWithinWindow() async throws {
- let counter = Counter()
- let debouncer = PushDebouncer(interval: .milliseconds(100)) {
- await counter.increment()
- }
-
- for _ in 0..<5 {
- await debouncer.schedule()
- try await Task.sleep(for: .milliseconds(20))
- }
-
- try await Task.sleep(for: .milliseconds(200))
- let count = await counter.count
- #expect(count == 1)
- }
-
- @Test("Flush runs the action immediately and cancels pending")
- func flushFiresImmediately() async throws {
- let counter = Counter()
- let debouncer = PushDebouncer(interval: .seconds(5)) {
- await counter.increment()
- }
-
- await debouncer.schedule()
- await debouncer.flush()
- let afterFlush = await counter.count
- #expect(afterFlush == 1)
-
- // The previously-scheduled task should have been cancelled by flush,
- // so waiting past the original interval must not add another call.
- try await Task.sleep(for: .milliseconds(200))
- let later = await counter.count
- #expect(later == 1)
- }
-
- @Test("Schedule after completion fires again")
- func secondBurstFires() async throws {
- let counter = Counter()
- let debouncer = PushDebouncer(interval: .milliseconds(80)) {
- await counter.increment()
- }
-
- await debouncer.schedule()
- try await Task.sleep(for: .milliseconds(200))
- let afterFirst = await counter.count
- #expect(afterFirst == 1)
-
- await debouncer.schedule()
- try await Task.sleep(for: .milliseconds(200))
- let afterSecond = await counter.count
- #expect(afterSecond == 2)
- }
-}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -17,23 +17,12 @@ struct RecordSerializerTests {
#expect(name == "game-12345678-1234-1234-1234-123456789ABC")
}
- @Test("Cell record name uses expected format")
- func cellRecordNameFormat() {
- let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
- let name = RecordSerializer.recordName(forCellInGame: id, row: 3, col: 7)
- #expect(name == "cell-12345678-1234-1234-1234-123456789ABC-3-7")
- }
-
@Test("Record names are deterministic")
func recordNamesAreDeterministic() {
let id = UUID()
let a = RecordSerializer.recordName(forGameID: id)
let b = RecordSerializer.recordName(forGameID: id)
#expect(a == b)
-
- let c = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0)
- let d = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0)
- #expect(c == d)
}
// MARK: - Zone
@@ -63,217 +52,3 @@ struct RecordSerializerTests {
}
}
-@Suite("PendingChangePayload")
-struct PendingChangePayloadTests {
-
- @Test("Cell payload round-trips through JSON")
- func cellPayloadRoundTrip() throws {
- let now = Date()
- let original = PendingChangePayload(
- recordType: .cell,
- recordName: "cell-uuid-0-1",
- letter: "A",
- markKind: 2,
- checkedWrong: false,
- updatedAt: now,
- letterAuthorID: "user123",
- parentGameRecordName: "game-uuid"
- )
-
- let data = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data)
-
- #expect(decoded.recordType == .cell)
- #expect(decoded.recordName == "cell-uuid-0-1")
- #expect(decoded.letter == "A")
- #expect(decoded.markKind == 2)
- #expect(decoded.checkedWrong == false)
- #expect(decoded.letterAuthorID == "user123")
- #expect(decoded.parentGameRecordName == "game-uuid")
- }
-
- @Test("Game payload round-trips through JSON")
- func gamePayloadRoundTrip() throws {
- let original = PendingChangePayload(
- recordType: .game,
- recordName: "game-uuid",
- title: "My Puzzle",
- puzzleSource: "Title: Test\n\nAB\nCD",
- completedAt: nil
- )
-
- let data = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data)
-
- #expect(decoded.recordType == .game)
- #expect(decoded.title == "My Puzzle")
- #expect(decoded.puzzleSource == "Title: Test\n\nAB\nCD")
- #expect(decoded.completedAt == nil)
- }
-}
-
-/// Regression coverage for the fetch-side overwrite bug documented in PLAN.md:
-/// an incoming CKRecord must not clobber unpushed local state when a pending
-/// outbox entry exists (or when the local entity is strictly newer).
-@Suite("RecordSerializer.applyCellRecord LWW", .serialized)
-@MainActor
-struct ApplyCellRecordLWWTests {
-
- private func makeCellRecord(
- gameID: UUID,
- row: Int,
- col: Int,
- letter: String,
- updatedAt: Date
- ) -> CKRecord {
- let zoneID = RecordSerializer.zoneID()
- let recordID = CKRecord.ID(
- recordName: RecordSerializer.recordName(forCellInGame: gameID, row: row, col: col),
- zoneID: zoneID
- )
- let record = CKRecord(recordType: "Cell", recordID: recordID)
- record["letter"] = letter as CKRecordValue
- record["markKind"] = 0 as CKRecordValue
- record["checkedWrong"] = false as CKRecordValue
- record["updatedAt"] = updatedAt as CKRecordValue
- return record
- }
-
- private func firstCell(
- in game: GameEntity,
- row: Int16 = 0,
- col: Int16 = 0
- ) -> CellEntity {
- let cells = (game.cells as? Set<CellEntity>) ?? []
- return cells.first { $0.row == row && $0.col == col }!
- }
-
- private func gameID(from entity: GameEntity) -> UUID {
- entity.id!
- }
-
- @Test("Server record applies when no pending change exists")
- func serverWinsWithNoPending() throws {
- let (_, _, gameEntity, _) = try makeTestGame()
- let context = gameEntity.managedObjectContext!
-
- let cell = firstCell(in: gameEntity)
- cell.letter = ""
- cell.updatedAt = Date(timeIntervalSince1970: 1_000)
- try context.save()
-
- let record = makeCellRecord(
- gameID: gameID(from: gameEntity),
- row: 0,
- col: 0,
- letter: "A",
- updatedAt: Date(timeIntervalSince1970: 2_000)
- )
-
- _ = RecordSerializer.applyCellRecord(record, to: context, game: gameEntity)
-
- #expect(firstCell(in: gameEntity).letter == "A")
- }
-
- @Test("Pending change with newer updatedAt preserves local letter")
- func localWinsWhenPendingIsNewer() throws {
- let (_, _, gameEntity, _) = try makeTestGame()
- let context = gameEntity.managedObjectContext!
- let id = gameID(from: gameEntity)
-
- let cell = firstCell(in: gameEntity)
- cell.letter = "A"
- let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
- cell.updatedAt = localUpdatedAt
- RecordSerializer.enqueueCellPending(for: cell, in: context)
- try context.save()
-
- let staleServerRecord = makeCellRecord(
- gameID: id,
- row: 0,
- col: 0,
- letter: "",
- updatedAt: Date(timeIntervalSince1970: 1_000)
- )
-
- _ = RecordSerializer.applyCellRecord(staleServerRecord, to: context, game: gameEntity)
-
- #expect(firstCell(in: gameEntity).letter == "A")
- #expect(firstCell(in: gameEntity).updatedAt == localUpdatedAt)
- }
-
- @Test("Pending change with older updatedAt yields to server")
- func serverWinsWhenPendingIsOlder() throws {
- let (_, _, gameEntity, _) = try makeTestGame()
- let context = gameEntity.managedObjectContext!
- let id = gameID(from: gameEntity)
-
- let cell = firstCell(in: gameEntity)
- cell.letter = "A"
- cell.updatedAt = Date(timeIntervalSince1970: 1_000)
- RecordSerializer.enqueueCellPending(for: cell, in: context)
- try context.save()
-
- let newerServerRecord = makeCellRecord(
- gameID: id,
- row: 0,
- col: 0,
- letter: "B",
- updatedAt: Date(timeIntervalSince1970: 2_000)
- )
-
- _ = RecordSerializer.applyCellRecord(newerServerRecord, to: context, game: gameEntity)
-
- #expect(firstCell(in: gameEntity).letter == "B")
- }
-
- @Test("Local entity updatedAt strictly newer than server preserves local")
- func localEntityNewerFallback() throws {
- let (_, _, gameEntity, _) = try makeTestGame()
- let context = gameEntity.managedObjectContext!
- let id = gameID(from: gameEntity)
-
- let cell = firstCell(in: gameEntity)
- cell.letter = "A"
- cell.updatedAt = Date(timeIntervalSince1970: 2_000)
- try context.save()
-
- let staleServerRecord = makeCellRecord(
- gameID: id,
- row: 0,
- col: 0,
- letter: "",
- updatedAt: Date(timeIntervalSince1970: 1_000)
- )
-
- _ = RecordSerializer.applyCellRecord(staleServerRecord, to: context, game: gameEntity)
-
- #expect(firstCell(in: gameEntity).letter == "A")
- }
-
- @Test("System fields are updated even when local content wins")
- func systemFieldsAlwaysUpdated() throws {
- let (_, _, gameEntity, _) = try makeTestGame()
- let context = gameEntity.managedObjectContext!
- let id = gameID(from: gameEntity)
-
- let cell = firstCell(in: gameEntity)
- cell.letter = "A"
- cell.updatedAt = Date(timeIntervalSince1970: 2_000)
- cell.ckSystemFields = nil
- RecordSerializer.enqueueCellPending(for: cell, in: context)
- try context.save()
-
- let staleServerRecord = makeCellRecord(
- gameID: id,
- row: 0,
- col: 0,
- letter: "",
- updatedAt: Date(timeIntervalSince1970: 1_000)
- )
-
- _ = RecordSerializer.applyCellRecord(staleServerRecord, to: context, game: gameEntity)
-
- #expect(firstCell(in: gameEntity).ckSystemFields != nil)
- }
-}