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:
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)