crossmate

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

commit 0812dac2de826334bc3c6fb64104ba42412f3b84
parent 24ef231551d8a3755259620f735f1e09a25065e1
Author: Michael Camilleri <[email protected]>
Date:   Wed,  6 May 2026 00:19:42 +0900

Make GameStore collaborators required at construction

Prior to this commit, GameStore exposed five late-wired Optional properties
(moveBuffer, authorIDProvider, onGameCreated, onGameUpdated, and onGameDeleted)
that AppServices assigned after construction. The outer Optional carried no
semantic meaning: in production the closures were always non-nil, and the
absent case existed only as an artifact of construction order.

The blocker for making them required init parameters was a cycle
between MoveBuffer and GameStore: MoveBuffer's afterFlush called
store.createSnapshotsIfNeeded(for:), so the buffer needed the store at
construction, but the store now wanted the buffer at construction too.

This commit extracts the snapshot lifecycle (createSnapshotsIfNeeded,
pruneMoves, and the pruneDurableSnapshots/usesSharedSync helpers) out of
GameStore into a new SnapshotService that only depends on
PersistenceController. MoveBuffer's afterFlush captures the service instead of
the store, the cycle disappears, and GameStore.init takes its five
collaborators as non-Optional parameters.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++----
MCrossmate/Persistence/GameStore.swift | 223+++++++++++--------------------------------------------------------------------
ACrossmate/Persistence/SnapshotService.swift | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 89++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
MTests/Support/TestHelpers.swift | 22++++++++++++++++++++++
MTests/Unit/GamePlayerColorStoreTests.swift | 10++++++----
DTests/Unit/GameStoreSnapshotPruningTests.swift | 199-------------------------------------------------------------------------------
MTests/Unit/GameStoreUnseenMovesTests.swift | 2+-
ATests/Unit/SnapshotServiceTests.swift | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/ShareRoutingTests.swift | 20++++++++++++--------
10 files changed, 510 insertions(+), 447 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -30,10 +30,12 @@ 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; + 7693606B3D06FCD27A50C239 /* SnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; @@ -44,7 +46,6 @@ 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; - 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; @@ -104,10 +105,10 @@ 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; - 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreSnapshotPruningTests.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; + 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotServiceTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; @@ -136,6 +137,7 @@ 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>"; }; + 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotService.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>"; }; @@ -216,7 +218,6 @@ children = ( BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */, - 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */, D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */, BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */, 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, @@ -226,6 +227,7 @@ 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, + 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); name = Unit; @@ -260,6 +262,7 @@ 43DC132D49361C56DE79C13E /* GameMutator.swift */, 93EE5BA78566EDED68D846AB /* GameStore.swift */, ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, + 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */, ); path = Persistence; sourceTree = "<group>"; @@ -470,7 +473,6 @@ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, - 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */, 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */, 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */, 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, @@ -482,6 +484,7 @@ 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, + 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -539,6 +542,7 @@ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, + 7693606B3D06FCD27A50C239 /* SnapshotService.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -198,34 +198,37 @@ final class GameStore { private(set) var currentMutator: GameMutator? private(set) var currentEntity: GameEntity? - /// Set by `AppServices` after construction so `GameMutator` instances have - /// a buffer to emit moves into. - @ObservationIgnored - var moveBuffer: MoveBuffer? + private let moveBuffer: MoveBuffer - /// Provides the current iCloud author ID at move-emit time. Set by - /// `AppServices` after `AuthorIdentity` is initialised. - @ObservationIgnored - var authorIDProvider: (@MainActor () -> String?)? + /// Returns the current iCloud author ID, or nil while the first + /// `userRecordID()` lookup is still pending. The inner Optional reflects + /// genuine "don't know yet" state on first install. + private let authorIDProvider: @MainActor () -> String? - /// Called when a new game's `ckRecordName` is ready to push. Wired to - /// `SyncEngine.enqueueGame` by `AppServices`. - @ObservationIgnored - var onGameCreated: ((String) -> Void)? + /// Called when a new game's `ckRecordName` is ready to push. + private let onGameCreated: (String) -> Void /// Called with CloudKit zone metadata after a game is removed locally. - /// Wired to `SyncEngine.enqueueDeleteGame` by `AppServices`. - @ObservationIgnored - var onGameDeleted: ((GameCloudDeletion) -> Void)? + private let onGameDeleted: (GameCloudDeletion) -> Void /// Called when a mutable field on the `Game` record (e.g. `completedAt`) - /// changes and needs to be re-pushed. Wired to `SyncEngine.enqueueGame` - /// by `AppServices`. - @ObservationIgnored - var onGameUpdated: ((String) -> Void)? - - init(persistence: PersistenceController) { + /// changes and needs to be re-pushed. + private let onGameUpdated: (String) -> Void + + init( + persistence: PersistenceController, + moveBuffer: MoveBuffer, + authorIDProvider: @escaping @MainActor () -> String?, + onGameCreated: @escaping (String) -> Void, + onGameUpdated: @escaping (String) -> Void, + onGameDeleted: @escaping (GameCloudDeletion) -> Void + ) { self.persistence = persistence + self.moveBuffer = moveBuffer + self.authorIDProvider = authorIDProvider + self.onGameCreated = onGameCreated + self.onGameUpdated = onGameUpdated + self.onGameDeleted = onGameDeleted } enum LoadError: Error { @@ -327,132 +330,6 @@ final class GameStore { } } - /// Checks each game in `gameIDs` and writes a `SnapshotEntity` if the - /// game is complete (has `completedAt` set) or has 200 or more moves not - /// yet covered by an existing snapshot. Moves folded into a local - /// compaction snapshot are pruned only after CloudKit has confirmed that - /// snapshot save. Returns the `ckRecordName` of each new snapshot for - /// enqueueing, plus any move deletions made for previously-durable - /// snapshots. - func createSnapshotsIfNeeded( - for gameIDs: Set<UUID> - ) async -> (snapshotNames: [String], prunedMoveNames: [String]) { - let bgCtx = persistence.container.newBackgroundContext() - bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - return await bgCtx.perform { - var snapshotNames: [String] = [] - let prunedMoveNames = Self.pruneDurableSnapshots(in: bgCtx) - - for gameID in gameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? bgCtx.fetch(req).first else { continue } - guard !Self.usesSharedSync(entity) else { continue } - - let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] - let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] - let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 - let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count - let highWater = entity.lamportHighWater - - let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) - && highWater > latestCoveredLamport - guard shouldSnapshot else { continue } - - let snapshots: [Snapshot] = allSnapshots.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 moves: [Move] = allMoves.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() - ) - } - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) - - let snapshotEntity = SnapshotEntity(context: bgCtx) - snapshotEntity.game = entity - snapshotEntity.upToLamport = highWater - snapshotEntity.createdAt = Date() - let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) - snapshotEntity.ckRecordName = ckName - snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) - snapshotEntity.needsPruning = true - - snapshotNames.append(ckName) - } - if bgCtx.hasChanges { - try? bgCtx.save() - } - return (snapshotNames, prunedMoveNames) - } - } - - /// Deletes moves covered by local compaction snapshots after there is - /// durable evidence that the snapshot exists in CloudKit. Passing names - /// is used directly from CKSyncEngine's saved-record callback; omitting - /// names performs crash recovery by looking for saved snapshots with - /// written-back system fields. - func pruneMoves( - ckRecordNames: Set<String>? = nil - ) -> [String] { - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - if let ckRecordNames { - guard !ckRecordNames.isEmpty else { return [] } - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckRecordName IN %@", - Array(ckRecordNames) - ) - } else { - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckSystemFields != nil" - ) - } - - let snapshots = (try? context.fetch(req)) ?? [] - var prunedMoveNames: [String] = [] - for snapshot in snapshots { - guard let game = snapshot.game else { - snapshot.needsPruning = false - continue - } - guard !Self.usesSharedSync(game) else { - snapshot.needsPruning = false - continue - } - - let covered = ((game.moves as? Set<MoveEntity>) ?? []) - .filter { $0.lamport <= snapshot.upToLamport } - for move in covered { - if let name = move.ckRecordName { - prunedMoveNames.append(name) - } - context.delete(move) - } - snapshot.needsPruning = false - } - - if context.hasChanges { - try? context.save() - } - return prunedMoveNames - } - // MARK: - Load a specific game /// Loads a game by its entity ID. Sets it as the current game. @@ -527,7 +404,7 @@ final class GameStore { entity.populateCachedSummaryFields(from: puzzle) try context.save() - onGameCreated?("game-\(gameID.uuidString)") + onGameCreated("game-\(gameID.uuidString)") return gameID } @@ -556,7 +433,7 @@ final class GameStore { context.delete(entity) try context.save() - onGameDeleted?(deletion) + onGameDeleted(deletion) } // MARK: - Resign a game @@ -571,9 +448,9 @@ final class GameStore { entity.completedAt = Date() try context.save() if let ckName = entity.ckRecordName { - onGameUpdated?(ckName) + onGameUpdated(ckName) } - Task { await moveBuffer?.flush() } + Task { await moveBuffer.flush() } // Clean up current references currentGame = nil @@ -593,9 +470,9 @@ final class GameStore { entity.completedAt = Date() try? context.save() if let ckName = entity.ckRecordName { - onGameUpdated?(ckName) + onGameUpdated(ckName) } - Task { await moveBuffer?.flush() } + Task { await moveBuffer.flush() } } // MARK: - Reset @@ -688,7 +565,7 @@ final class GameStore { entity.populateCachedSummaryFields(from: puzzle) try context.save() - onGameCreated?("game-\(gameID.uuidString)") + onGameCreated("game-\(gameID.uuidString)") return (entity, puzzle) } @@ -787,44 +664,6 @@ final class GameStore { } } - /// Background-context equivalent of `pruneMoves(ckRecordNames: nil)` — - /// drops moves covered by snapshots whose CloudKit system fields prove - /// the snapshot is durable. Caller is responsible for saving `ctx`. - fileprivate nonisolated static func pruneDurableSnapshots( - in ctx: NSManagedObjectContext - ) -> [String] { - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckSystemFields != nil" - ) - let snapshots = (try? ctx.fetch(req)) ?? [] - var prunedMoveNames: [String] = [] - for snapshot in snapshots { - guard let game = snapshot.game else { - snapshot.needsPruning = false - continue - } - guard !Self.usesSharedSync(game) else { - snapshot.needsPruning = false - continue - } - let covered = ((game.moves as? Set<MoveEntity>) ?? []) - .filter { $0.lamport <= snapshot.upToLamport } - for move in covered { - if let name = move.ckRecordName { - prunedMoveNames.append(name) - } - ctx.delete(move) - } - snapshot.needsPruning = false - } - return prunedMoveNames - } - - fileprivate nonisolated static func usesSharedSync(_ game: GameEntity) -> Bool { - game.databaseScope == 1 || game.ckShareRecordName != nil - } - /// Marks the active game read-only when the sync engine sees its shared /// zone disappear from the shared database (owner revoked access). func markAccessRevoked(gameID: UUID) { diff --git a/Crossmate/Persistence/SnapshotService.swift b/Crossmate/Persistence/SnapshotService.swift @@ -0,0 +1,181 @@ +import CoreData +import Foundation + +/// Owns the snapshot-creation and move-pruning lifecycle for local +/// (single-user) games. Lives outside `GameStore` so that +/// `MoveBuffer.afterFlush` can run snapshot work without depending on +/// `GameStore`, which lets `GameStore` accept its collaborators as required +/// init parameters. +@MainActor +final class SnapshotService { + let persistence: PersistenceController + private var context: NSManagedObjectContext { persistence.viewContext } + + init(persistence: PersistenceController) { + self.persistence = persistence + } + + /// Checks each game in `gameIDs` and writes a `SnapshotEntity` if the + /// game is complete (has `completedAt` set) or has 200 or more moves not + /// yet covered by an existing snapshot. Moves folded into a local + /// compaction snapshot are pruned only after CloudKit has confirmed that + /// snapshot save. Returns the `ckRecordName` of each new snapshot for + /// enqueueing, plus any move deletions made for previously-durable + /// snapshots. + func createSnapshotsIfNeeded( + for gameIDs: Set<UUID> + ) async -> (snapshotNames: [String], prunedMoveNames: [String]) { + let bgCtx = persistence.container.newBackgroundContext() + bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + return await bgCtx.perform { + var snapshotNames: [String] = [] + let prunedMoveNames = Self.pruneDurableSnapshots(in: bgCtx) + + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? bgCtx.fetch(req).first else { continue } + guard !Self.usesSharedSync(entity) else { continue } + + let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] + let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] + let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 + let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count + let highWater = entity.lamportHighWater + + let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) + && highWater > latestCoveredLamport + guard shouldSnapshot else { continue } + + let snapshots: [Snapshot] = allSnapshots.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 moves: [Move] = allMoves.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() + ) + } + let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + + let snapshotEntity = SnapshotEntity(context: bgCtx) + snapshotEntity.game = entity + snapshotEntity.upToLamport = highWater + snapshotEntity.createdAt = Date() + let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) + snapshotEntity.ckRecordName = ckName + snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) + snapshotEntity.needsPruning = true + + snapshotNames.append(ckName) + } + if bgCtx.hasChanges { + try? bgCtx.save() + } + return (snapshotNames, prunedMoveNames) + } + } + + /// Deletes moves covered by local compaction snapshots after there is + /// durable evidence that the snapshot exists in CloudKit. Passing names + /// is used directly from CKSyncEngine's saved-record callback; omitting + /// names performs crash recovery by looking for saved snapshots with + /// written-back system fields. + func pruneMoves( + ckRecordNames: Set<String>? = nil + ) -> [String] { + let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + if let ckRecordNames { + guard !ckRecordNames.isEmpty else { return [] } + req.predicate = NSPredicate( + format: "needsPruning == YES AND ckRecordName IN %@", + Array(ckRecordNames) + ) + } else { + req.predicate = NSPredicate( + format: "needsPruning == YES AND ckSystemFields != nil" + ) + } + + let snapshots = (try? context.fetch(req)) ?? [] + var prunedMoveNames: [String] = [] + for snapshot in snapshots { + guard let game = snapshot.game else { + snapshot.needsPruning = false + continue + } + guard !Self.usesSharedSync(game) else { + snapshot.needsPruning = false + continue + } + + let covered = ((game.moves as? Set<MoveEntity>) ?? []) + .filter { $0.lamport <= snapshot.upToLamport } + for move in covered { + if let name = move.ckRecordName { + prunedMoveNames.append(name) + } + context.delete(move) + } + snapshot.needsPruning = false + } + + if context.hasChanges { + try? context.save() + } + return prunedMoveNames + } + + /// Background-context equivalent of `pruneMoves(ckRecordNames: nil)` — + /// drops moves covered by snapshots whose CloudKit system fields prove + /// the snapshot is durable. Caller is responsible for saving `ctx`. + fileprivate nonisolated static func pruneDurableSnapshots( + in ctx: NSManagedObjectContext + ) -> [String] { + let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + req.predicate = NSPredicate( + format: "needsPruning == YES AND ckSystemFields != nil" + ) + let snapshots = (try? ctx.fetch(req)) ?? [] + var prunedMoveNames: [String] = [] + for snapshot in snapshots { + guard let game = snapshot.game else { + snapshot.needsPruning = false + continue + } + guard !Self.usesSharedSync(game) else { + snapshot.needsPruning = false + continue + } + let covered = ((game.moves as? Set<MoveEntity>) ?? []) + .filter { $0.lamport <= snapshot.upToLamport } + for move in covered { + if let name = move.ckRecordName { + prunedMoveNames.append(name) + } + ctx.delete(move) + } + snapshot.needsPruning = false + } + return prunedMoveNames + } + + fileprivate nonisolated static func usesSharedSync(_ game: GameEntity) -> Bool { + game.databaseScope == 1 || game.ckShareRecordName != nil + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -12,6 +12,7 @@ final class AppServices { let driveMonitor: DriveMonitor let nytFetcher: NYTPuzzleFetcher let moveBuffer: MoveBuffer + let snapshotService: SnapshotService let presencePublisher: PresencePublisher let identity: AuthorIdentity let shareController: ShareController @@ -32,9 +33,8 @@ final class AppServices { init() { let preferences = PlayerPreferences() self.preferences = preferences - self.persistence = PersistenceController() - let store = GameStore(persistence: persistence) - self.store = store + let persistence = PersistenceController() + self.persistence = persistence let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) self.syncEngine = syncEngine self.syncMonitor = SyncMonitor() @@ -43,15 +43,10 @@ final class AppServices { self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } let identity = AuthorIdentity() self.identity = identity - self.shareController = ShareController( - container: self.ckContainer, - persistence: persistence, - syncEngine: syncEngine, - syncMonitor: self.syncMonitor - ) - self.shareController.onShareSaved = { [weak store] gameID in - store?.markShared(gameID: gameID) - } + + let snapshotService = SnapshotService(persistence: persistence) + self.snapshotService = snapshotService + let moveBuffer = MoveBuffer( debounceInterval: .milliseconds(1500), persistence: persistence, @@ -64,7 +59,7 @@ final class AppServices { let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } - let result = await store.createSnapshotsIfNeeded(for: gameIDs) + let result = await snapshotService.createSnapshotsIfNeeded(for: gameIDs) for name in result.snapshotNames { await syncEngine.enqueueSnapshot(ckRecordName: name) @@ -83,7 +78,46 @@ final class AppServices { } ) self.moveBuffer = moveBuffer - store.moveBuffer = moveBuffer + + let colorStore = GamePlayerColorStore() + self.colorStore = colorStore + let onGameDeletedHandler = Self.makeOnGameDeleted( + syncEngine: syncEngine, + colorStore: colorStore + ) + + let store = GameStore( + persistence: persistence, + moveBuffer: moveBuffer, + authorIDProvider: { identity.currentID }, + onGameCreated: { [preferences, syncEngine] ckRecordName in + Task { + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } + await syncEngine.enqueueGame(ckRecordName: ckRecordName) + } + }, + onGameUpdated: { [preferences, syncEngine] ckRecordName in + Task { + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } + await syncEngine.enqueueGame(ckRecordName: ckRecordName) + } + }, + onGameDeleted: { [preferences] deletion in + guard preferences.isICloudSyncEnabled else { return } + onGameDeletedHandler(deletion) + } + ) + self.store = store + + self.shareController = ShareController( + container: self.ckContainer, + persistence: persistence, + syncEngine: syncEngine, + syncMonitor: self.syncMonitor + ) + self.shareController.onShareSaved = { [weak store] gameID in + store?.markShared(gameID: gameID) + } self.presencePublisher = PresencePublisher( persistence: persistence, sink: { gameID, authorID in @@ -92,29 +126,6 @@ final class AppServices { await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) } ) - store.authorIDProvider = { identity.currentID } - store.onGameCreated = { [preferences, syncEngine] ckRecordName in - Task { - guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } - await syncEngine.enqueueGame(ckRecordName: ckRecordName) - } - } - store.onGameUpdated = { [preferences, syncEngine] ckRecordName in - Task { - guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } - await syncEngine.enqueueGame(ckRecordName: ckRecordName) - } - } - let colorStore = GamePlayerColorStore() - let onGameDeleted = Self.makeOnGameDeleted( - syncEngine: syncEngine, - colorStore: colorStore - ) - store.onGameDeleted = { [preferences] deletion in - guard preferences.isICloudSyncEnabled else { return } - onGameDeleted(deletion) - } - self.colorStore = colorStore self.cloudService = CloudService( container: self.ckContainer, syncEngine: syncEngine, @@ -164,8 +175,8 @@ final class AppServices { store.markAccessRevoked(gameID: gameID) } - await syncEngine.setOnSnapshotsSaved { [store, syncEngine] names in - let prunedMoveNames = store.pruneMoves( + await syncEngine.setOnSnapshotsSaved { [snapshotService, syncEngine] names in + let prunedMoveNames = snapshotService.pruneMoves( ckRecordNames: Set(names) ) await syncEngine.enqueueDeleteRecords(prunedMoveNames) diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -9,6 +9,28 @@ func makeTestPersistence() -> PersistenceController { PersistenceController(inMemory: true) } +/// Builds a `GameStore` wired with no-op collaborators for tests that don't +/// exercise the sync/identity/buffer paths. Override only what the test needs. +@MainActor +func makeTestStore( + persistence: PersistenceController, + moveBuffer: MoveBuffer? = nil, + authorIDProvider: @escaping @MainActor () -> String? = { nil }, + onGameCreated: @escaping (String) -> Void = { _ in }, + onGameUpdated: @escaping (String) -> Void = { _ in }, + onGameDeleted: @escaping (GameCloudDeletion) -> Void = { _ in } +) -> GameStore { + let buffer = moveBuffer ?? MoveBuffer(persistence: persistence, sink: { _ in }) + return GameStore( + persistence: persistence, + moveBuffer: buffer, + authorIDProvider: authorIDProvider, + onGameCreated: onGameCreated, + onGameUpdated: onGameUpdated, + onGameDeleted: onGameDeleted + ) +} + /// 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. diff --git a/Tests/Unit/GamePlayerColorStoreTests.swift b/Tests/Unit/GamePlayerColorStoreTests.swift @@ -164,7 +164,6 @@ struct GamePlayerColorStoreTests { entity.ckRecordName = "game-\(gameID.uuidString)" try ctx.save() - let store = GameStore(persistence: persistence) let colorStore = makeStore() colorStore.setColor(.red, forGame: gameID, authorID: "_A") colorStore.setColor(.blue, forGame: gameID, authorID: "_B") @@ -173,9 +172,12 @@ struct GamePlayerColorStoreTests { // ever drops the colour-cleanup branch. let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") let syncEngine = SyncEngine(container: container, persistence: persistence) - store.onGameDeleted = AppServices.makeOnGameDeleted( - syncEngine: syncEngine, - colorStore: colorStore + let store = makeTestStore( + persistence: persistence, + onGameDeleted: AppServices.makeOnGameDeleted( + syncEngine: syncEngine, + colorStore: colorStore + ) ) try store.deleteGame(id: gameID) diff --git a/Tests/Unit/GameStoreSnapshotPruningTests.swift b/Tests/Unit/GameStoreSnapshotPruningTests.swift @@ -1,199 +0,0 @@ -import CoreData -import Foundation -import Testing - -@testable import Crossmate - -@Suite("GameStore snapshot pruning", .serialized) -@MainActor -struct GameStoreSnapshotPruningTests { - @Test("Creating a compaction snapshot keeps covered moves until the snapshot is saved") - func snapshotCreationDoesNotImmediatelyPruneMoves() async throws { - let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 2) - - let result = await store.createSnapshotsIfNeeded(for: [gameID]) - store.persistence.viewContext.refreshAllObjects() - - #expect(result.snapshotNames.count == 1) - #expect(result.prunedMoveNames.isEmpty) - #expect(fetchMoveNames(store: store, gameID: gameID).count == 2) - #expect(fetchPendingPruneSnapshotNames(store: store) == result.snapshotNames) - } - - @Test("Saved local compaction snapshots prune their covered moves") - func savedSnapshotPrunesCoveredMoves() async throws { - let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 2) - let result = await store.createSnapshotsIfNeeded(for: [gameID]) - store.persistence.viewContext.refreshAllObjects() - - let pruned = store.pruneMoves( - ckRecordNames: Set(result.snapshotNames) - ) - - #expect(Set(pruned) == Set([ - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 2) - ])) - #expect(fetchMoveNames(store: store, gameID: gameID).isEmpty) - #expect(fetchPendingPruneSnapshotNames(store: store).isEmpty) - } - - @Test("Durable pending snapshots are pruned on a later pass") - func durablePendingSnapshotPrunesOnRecoveryPass() async throws { - let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 1) - let result = await store.createSnapshotsIfNeeded(for: [gameID]) - store.persistence.viewContext.refreshAllObjects() - try markSnapshotSaved(store: store, ckRecordName: result.snapshotNames[0]) - - let pruned = store.pruneMoves() - - #expect(pruned == [ - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) - ]) - #expect(fetchMoveNames(store: store, gameID: gameID).isEmpty) - #expect(fetchPendingPruneSnapshotNames(store: store).isEmpty) - } - - @Test("Remote snapshots are not used to prune local moves") - func remoteSnapshotDoesNotPruneLocalMoves() throws { - let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 1) - let context = store.persistence.viewContext - let game = try #require(try fetchGame(store: store, gameID: gameID)) - let snapshot = SnapshotEntity(context: context) - snapshot.game = game - snapshot.ckRecordName = "snapshot-\(gameID.uuidString)-1-remote" - snapshot.ckSystemFields = Data([1]) - snapshot.createdAt = Date() - snapshot.gridState = try MoveLog.encodeGridState([:]) - snapshot.upToLamport = 1 - snapshot.needsPruning = false - try context.save() - - let pruned = store.pruneMoves() - - #expect(pruned.isEmpty) - #expect(fetchMoveNames(store: store, gameID: gameID).count == 1) - } - - @Test("Shared games do not create scalar-Lamport snapshots") - func sharedGamesDoNotCreateSnapshots() async throws { - let (store, gameID) = try makeStoreWithCompletedGame( - moveCount: 2, - configure: { game in - game.databaseScope = 1 - } - ) - - let result = await store.createSnapshotsIfNeeded(for: [gameID]) - store.persistence.viewContext.refreshAllObjects() - - #expect(result.snapshotNames.isEmpty) - #expect(result.prunedMoveNames.isEmpty) - #expect(fetchMoveNames(store: store, gameID: gameID).count == 2) - #expect(fetchPendingPruneSnapshotNames(store: store).isEmpty) - } - - @Test("Shared games do not prune moves from existing scalar snapshots") - func sharedGamesDoNotPruneExistingSnapshots() throws { - let (store, gameID) = try makeStoreWithCompletedGame( - moveCount: 1, - configure: { game in - game.ckShareRecordName = "share-test" - } - ) - let context = store.persistence.viewContext - let game = try #require(try fetchGame(store: store, gameID: gameID)) - let snapshot = SnapshotEntity(context: context) - snapshot.game = game - snapshot.ckRecordName = RecordSerializer.recordName( - forSnapshotInGame: gameID, - upToLamport: 1 - ) - snapshot.ckSystemFields = Data([1]) - snapshot.createdAt = Date() - snapshot.gridState = try MoveLog.encodeGridState([:]) - snapshot.upToLamport = 1 - snapshot.needsPruning = true - try context.save() - - let pruned = store.pruneMoves() - - #expect(pruned.isEmpty) - #expect(fetchMoveNames(store: store, gameID: gameID).count == 1) - #expect(fetchPendingPruneSnapshotNames(store: store).isEmpty) - } - - private func makeStoreWithCompletedGame( - moveCount: Int, - configure: (GameEntity) -> Void = { _ in } - ) throws -> (GameStore, UUID) { - let persistence = makeTestPersistence() - let store = GameStore(persistence: persistence) - let context = persistence.viewContext - let gameID = UUID() - let game = GameEntity(context: context) - game.id = gameID - game.title = "Test" - game.puzzleSource = "" - game.createdAt = Date() - game.updatedAt = Date() - game.completedAt = Date() - game.ckRecordName = "game-\(gameID.uuidString)" - game.lamportHighWater = Int64(moveCount) - configure(game) - - for lamport in 1...moveCount { - let move = MoveEntity(context: context) - move.game = game - move.lamport = Int64(lamport) - move.row = 0 - move.col = Int16(lamport - 1) - move.letter = "\(lamport)" - move.markKind = 0 - move.checkedWrong = false - move.createdAt = Date() - move.ckRecordName = RecordSerializer.recordName( - forMoveInGame: gameID, - lamport: Int64(lamport) - ) - } - - try context.save() - return (store, gameID) - } - - private func fetchGame(store: GameStore, gameID: UUID) throws -> GameEntity? { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - request.fetchLimit = 1 - return try store.persistence.viewContext.fetch(request).first - } - - private func fetchMoveNames(store: GameStore, gameID: UUID) -> [String] { - let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) - request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] - return ((try? store.persistence.viewContext.fetch(request)) ?? []) - .compactMap(\.ckRecordName) - } - - private func fetchPendingPruneSnapshotNames(store: GameStore) -> [String] { - let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - request.predicate = NSPredicate(format: "needsPruning == YES") - return ((try? store.persistence.viewContext.fetch(request)) ?? []) - .compactMap(\.ckRecordName) - .sorted() - } - - private func markSnapshotSaved( - store: GameStore, - ckRecordName: String - ) throws { - let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - request.predicate = NSPredicate(format: "ckRecordName == %@", ckRecordName) - request.fetchLimit = 1 - let snapshot = try #require(store.persistence.viewContext.fetch(request).first) - snapshot.ckSystemFields = Data([1]) - try store.persistence.viewContext.save() - } -} diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -72,7 +72,7 @@ struct GameStoreUnseenMovesTests { private func makeStoreWithSharedGame() throws -> (GameStore, UUID) { let persistence = makeTestPersistence() - let store = GameStore(persistence: persistence) + let store = makeTestStore(persistence: persistence) let context = persistence.viewContext let gameID = UUID() let entity = GameEntity(context: context) diff --git a/Tests/Unit/SnapshotServiceTests.swift b/Tests/Unit/SnapshotServiceTests.swift @@ -0,0 +1,199 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("SnapshotService", .serialized) +@MainActor +struct SnapshotServiceTests { + @Test("Creating a compaction snapshot keeps covered moves until the snapshot is saved") + func snapshotCreationDoesNotImmediatelyPruneMoves() async throws { + let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) + + let result = await service.createSnapshotsIfNeeded(for: [gameID]) + service.persistence.viewContext.refreshAllObjects() + + #expect(result.snapshotNames.count == 1) + #expect(result.prunedMoveNames.isEmpty) + #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) + #expect(fetchPendingPruneSnapshotNames(service: service) == result.snapshotNames) + } + + @Test("Saved local compaction snapshots prune their covered moves") + func savedSnapshotPrunesCoveredMoves() async throws { + let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) + let result = await service.createSnapshotsIfNeeded(for: [gameID]) + service.persistence.viewContext.refreshAllObjects() + + let pruned = service.pruneMoves( + ckRecordNames: Set(result.snapshotNames) + ) + + #expect(Set(pruned) == Set([ + RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), + RecordSerializer.recordName(forMoveInGame: gameID, lamport: 2) + ])) + #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) + #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) + } + + @Test("Durable pending snapshots are pruned on a later pass") + func durablePendingSnapshotPrunesOnRecoveryPass() async throws { + let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) + let result = await service.createSnapshotsIfNeeded(for: [gameID]) + service.persistence.viewContext.refreshAllObjects() + try markSnapshotSaved(service: service, ckRecordName: result.snapshotNames[0]) + + let pruned = service.pruneMoves() + + #expect(pruned == [ + RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) + ]) + #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) + #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) + } + + @Test("Remote snapshots are not used to prune local moves") + func remoteSnapshotDoesNotPruneLocalMoves() throws { + let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) + let context = service.persistence.viewContext + let game = try #require(try fetchGame(service: service, gameID: gameID)) + let snapshot = SnapshotEntity(context: context) + snapshot.game = game + snapshot.ckRecordName = "snapshot-\(gameID.uuidString)-1-remote" + snapshot.ckSystemFields = Data([1]) + snapshot.createdAt = Date() + snapshot.gridState = try MoveLog.encodeGridState([:]) + snapshot.upToLamport = 1 + snapshot.needsPruning = false + try context.save() + + let pruned = service.pruneMoves() + + #expect(pruned.isEmpty) + #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) + } + + @Test("Shared games do not create scalar-Lamport snapshots") + func sharedGamesDoNotCreateSnapshots() async throws { + let (service, gameID) = try makeServiceWithCompletedGame( + moveCount: 2, + configure: { game in + game.databaseScope = 1 + } + ) + + let result = await service.createSnapshotsIfNeeded(for: [gameID]) + service.persistence.viewContext.refreshAllObjects() + + #expect(result.snapshotNames.isEmpty) + #expect(result.prunedMoveNames.isEmpty) + #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) + #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) + } + + @Test("Shared games do not prune moves from existing scalar snapshots") + func sharedGamesDoNotPruneExistingSnapshots() throws { + let (service, gameID) = try makeServiceWithCompletedGame( + moveCount: 1, + configure: { game in + game.ckShareRecordName = "share-test" + } + ) + let context = service.persistence.viewContext + let game = try #require(try fetchGame(service: service, gameID: gameID)) + let snapshot = SnapshotEntity(context: context) + snapshot.game = game + snapshot.ckRecordName = RecordSerializer.recordName( + forSnapshotInGame: gameID, + upToLamport: 1 + ) + snapshot.ckSystemFields = Data([1]) + snapshot.createdAt = Date() + snapshot.gridState = try MoveLog.encodeGridState([:]) + snapshot.upToLamport = 1 + snapshot.needsPruning = true + try context.save() + + let pruned = service.pruneMoves() + + #expect(pruned.isEmpty) + #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) + #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) + } + + private func makeServiceWithCompletedGame( + moveCount: Int, + configure: (GameEntity) -> Void = { _ in } + ) throws -> (SnapshotService, UUID) { + let persistence = makeTestPersistence() + let service = SnapshotService(persistence: persistence) + let context = persistence.viewContext + let gameID = UUID() + let game = GameEntity(context: context) + game.id = gameID + game.title = "Test" + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + game.completedAt = Date() + game.ckRecordName = "game-\(gameID.uuidString)" + game.lamportHighWater = Int64(moveCount) + configure(game) + + for lamport in 1...moveCount { + let move = MoveEntity(context: context) + move.game = game + move.lamport = Int64(lamport) + move.row = 0 + move.col = Int16(lamport - 1) + move.letter = "\(lamport)" + move.markKind = 0 + move.checkedWrong = false + move.createdAt = Date() + move.ckRecordName = RecordSerializer.recordName( + forMoveInGame: gameID, + lamport: Int64(lamport) + ) + } + + try context.save() + return (service, gameID) + } + + private func fetchGame(service: SnapshotService, gameID: UUID) throws -> GameEntity? { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + return try service.persistence.viewContext.fetch(request).first + } + + private func fetchMoveNames(service: SnapshotService, gameID: UUID) -> [String] { + let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") + request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) + request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] + return ((try? service.persistence.viewContext.fetch(request)) ?? []) + .compactMap(\.ckRecordName) + } + + private func fetchPendingPruneSnapshotNames(service: SnapshotService) -> [String] { + let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + request.predicate = NSPredicate(format: "needsPruning == YES") + return ((try? service.persistence.viewContext.fetch(request)) ?? []) + .compactMap(\.ckRecordName) + .sorted() + } + + private func markSnapshotSaved( + service: SnapshotService, + ckRecordName: String + ) throws { + let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + request.predicate = NSPredicate(format: "ckRecordName == %@", ckRecordName) + request.fetchLimit = 1 + let snapshot = try #require(service.persistence.viewContext.fetch(request).first) + snapshot.ckSystemFields = Data([1]) + try service.persistence.viewContext.save() + } +} diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift @@ -161,11 +161,13 @@ struct ShareRoutingTests { let engine = SyncEngine(container: container, persistence: persistence) await engine.start() - let store = GameStore(persistence: persistence) var capturedDeletion: GameCloudDeletion? - store.onGameDeleted = { deletion in - capturedDeletion = deletion - } + let store = makeTestStore( + persistence: persistence, + onGameDeleted: { deletion in + capturedDeletion = deletion + } + ) try store.deleteGame(id: gameID) let deletion = try #require(capturedDeletion) await engine.enqueueDeleteGame(deletion) @@ -199,11 +201,13 @@ struct ShareRoutingTests { let engine = SyncEngine(container: container, persistence: persistence) await engine.start() - let store = GameStore(persistence: persistence) var capturedDeletion: GameCloudDeletion? - store.onGameDeleted = { deletion in - capturedDeletion = deletion - } + let store = makeTestStore( + persistence: persistence, + onGameDeleted: { deletion in + capturedDeletion = deletion + } + ) try store.deleteGame(id: gameID) let deletion = try #require(capturedDeletion) await engine.enqueueDeleteGame(deletion)