crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 32++++++++------------------------
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 14+-------------
MCrossmate/Models/PlayerSession.swift | 22+++++++++++-----------
MCrossmate/Persistence/GameMutator.swift | 274++++++++++++++-----------------------------------------------------------------
MCrossmate/Persistence/GameStore.swift | 234++++++++++++++++++++++++++++++++-----------------------------------------------
DCrossmate/Persistence/OutboxRecorder.swift | 91-------------------------------------------------------------------------------
DCrossmate/Persistence/PendingChange+Helpers.swift | 40----------------------------------------
MCrossmate/Services/AppServices.swift | 51++++++++++++++++++++++-----------------------------
ACrossmate/Sync/MoveBuffer.swift | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Sync/PendingChangePayload.swift | 29-----------------------------
DCrossmate/Sync/PushDebouncer.swift | 44--------------------------------------------
MCrossmate/Sync/RecordSerializer.swift | 245-------------------------------------------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 1436++++++++++++++++++++++++++++---------------------------------------------------
MCrossmate/Sync/SyncState+Helpers.swift | 19-------------------
MCrossmate/Views/SyncDiagnosticsView.swift | 17++++-------------
MTests/Support/TestHelpers.swift | 16++--------------
MTests/Unit/GameMutatorTests.swift | 153+++++--------------------------------------------------------------------------
ATests/Unit/MoveBufferTests.swift | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTests/Unit/PendingChangeTests.swift | 133-------------------------------------------------------------------------------
DTests/Unit/PushDebouncerTests.swift | 68--------------------------------------------------------------------
MTests/Unit/RecordSerializerTests.swift | 225-------------------------------------------------------------------------------
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) - } -}