crossmate

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

commit 3f2f636955bf590e0aaac38d3ee2eb2c4a36d010
parent cecfd1cf4b2d1a21796c881e36305cb8d779cac1
Author: Michael Camilleri <[email protected]>
Date:   Mon, 27 Jul 2026 08:06:15 +0900

Resolve sync scope from the engine's database, not its identity

A device could reach a state where a puzzle refused to open from the
Game List, reporting 'Couldn't load puzzle' with the parse failure '.xd
source has no grid section', while the same puzzle stayed playable
elsewhere. The device held two GameEntity rows for the one game — the
real one and an empty 'Joining…' placeholder — and loadGame(id:) fetches
on id alone with a fetch limit of one and no ordering, so it could pick
either. GameSummary.init? filters a row it cannot parse, so the card
rendered from the real row while the open path loaded the broken one.

The placeholder came from handleEvent resolving an engine's database by
comparing it against privateEngine and sharedEngine. resetSyncState
replaces both instances — the account-switch and v4-container purges
both call it — and a fetch still in flight from a replaced engine
matches neither property, so the comparison fell through to shared. The
private database's zone changes then ran the shared branch, which seats
a placeholder for every newly-visible zone, and every game the user
owned gained a row at databaseScope 1 carrying the owner placeholder.
Neither scope's gameIdentityPredicate can match that row again, so the
arriving Game record forked a second row rather than filling it in.

Scope is now read from the engine's own database, which is immutable for
its lifetime and so stays correct across a swap. That also corrects
nextFetchChangesOptions, which was applying the wrong scope's fetch
exclusions, and makeRecordZoneChangeBatch, where a replaced shared
engine built its outbound records from privately-matched rows.

Two guards close the shape at its source. The placeholder branch refuses
a zone whose owner is CKCurrentUserDefaultName, since a zone in the
shared database always belongs to somebody else, and constructJoinedGame
throws rather than normalising such a zone's owner to nil. Zone identity
stays part of gameIdentityPredicate: it is what keeps a forged shared
record from landing on a private row.

A one-shot migration repairs devices already carrying the damage. A row
at databaseScope 1 holding a nil or placeholder owner and no puzzle is
merged into its surviving sibling, adopting the moves, journal and
player rows only that row holds and leaving the derived cell and
peer-change caches to rebuild; where no sibling exists the row is
repaired to private scope so the ordinary sync path adopts it. The pass
is gated on a stored flag rather than its predicate because it deletes
rows, and is marked for removal as one block.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 6+++++-
MCrossmate/Persistence/GameStore.swift | 16++++++++++++++--
MCrossmate/Persistence/PersistenceController.swift | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 44+++++++++++++++++++++++++++++++++-----------
MTests/Unit/Sync/InboundRecordIdentityTests.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/PersistenceControllerHealTests.swift | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 503 insertions(+), 14 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; }; 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */; }; 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; }; + 9B1162B1714415FE58CBAE2C /* PersistenceControllerHealTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54A448620E71515AAA7BA3DD /* PersistenceControllerHealTests.swift */; }; 9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B1734CF731C2E405A39159 /* PuzzleView.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; 9DDFDF9D97FFC4F043CE84D8 /* PuzzleFileReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A1BF1EBD7D9E4584EF5FE /* PuzzleFileReader.swift */; }; @@ -365,6 +366,7 @@ 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortener.swift; sourceTree = "<group>"; }; + 54A448620E71515AAA7BA3DD /* PersistenceControllerHealTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerHealTests.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 57B1734CF731C2E405A39159 /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRouteTests.swift; sourceTree = "<group>"; }; @@ -861,6 +863,7 @@ 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */, BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, + 54A448620E71515AAA7BA3DD /* PersistenceControllerHealTests.swift */, 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */, BD32BDDE9B007B11BE86B7A0 /* PushCredentialRotationTests.swift */, 960353A6F38DD290112A2FE1 /* RemoteCoordinateValidationTests.swift */, @@ -1032,8 +1035,8 @@ projectRoot = ""; targets = ( 7708D1C8A0145D43BD15DEB7 /* Crossmate */, - C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, 350366A68B75DC4BDA91F8E5 /* NotificationService */, + C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, ); }; /* End PBXProject section */ @@ -1122,6 +1125,7 @@ A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */, 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, + 9B1162B1714415FE58CBAE2C /* PersistenceControllerHealTests.swift in Sources */, 26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */, 95B4083F8BC2CA465077A662 /* PlayerColorTests.swift in Sources */, CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -781,6 +781,10 @@ final class GameStore { case sampleResourceMissing case persistedSourceMissing case gameNotFound + /// A joined game was offered a zone the current user owns. Shared + /// games live in somebody else's zone by definition, so this is a + /// mis-routed zone identity, not a game we can seat locally. + case sharedZoneOwnedByCurrentUser } // MARK: - Remote update @@ -1407,6 +1411,15 @@ final class GameStore { source: String, notification: String? = nil ) throws { + // A share's zone always belongs to its owner, so an own-owner zone + // here is a mis-routed identity. Refuse it rather than normalising the + // owner to nil: that spelling means "private zone" everywhere else, and + // a `databaseScope == 1` row carrying it can never be matched by either + // scope's `gameIdentityPredicate` again. The caller falls back to the + // ordinary shared-zone fetch. + guard zoneID.ownerName != CKCurrentUserDefaultName else { + throw LoadError.sharedZoneOwnedByCurrentUser + } let recordName = "game-\(gameID.uuidString)" let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") existing.predicate = NSPredicate(format: "ckRecordName == %@", recordName) @@ -1433,8 +1446,7 @@ final class GameStore { entity.updatedAt = now entity.ckRecordName = recordName entity.ckZoneName = zoneID.zoneName - entity.ckZoneOwnerName = - zoneID.ownerName == CKCurrentUserDefaultName ? nil : zoneID.ownerName + entity.ckZoneOwnerName = zoneID.ownerName entity.databaseScope = 1 entity.syncVersion = GameSyncVersion.legacy entity.notification = notification diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -1,3 +1,4 @@ +import CloudKit import CoreData import Foundation @@ -61,6 +62,11 @@ final class PersistenceController { container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump if !inMemory { + // Synchronous, unlike the backfills below: a stranded row shadows + // the real game in every `id`-keyed lookup, so leaving a window + // where the library is live but the heal hasn't landed means the + // user can still open the broken row on this launch. + healStrandedSharedGameRows_v1() backfillCachedSummaryFields() backfillZoneIdentityFields() } @@ -136,6 +142,155 @@ final class PersistenceController { return preserved } + // MARK: - TEMPORARY v1.1 MIGRATION + + /// Repairs `GameEntity` rows stranded by the sync-engine scope bug: while + /// `SyncEngine.handleEvent` resolved an engine's database by instance + /// identity, a fetch still in flight from an engine that `resetSyncState` + /// had just replaced (the account-switch and v4-container purges both + /// replace both engines) resolved to *shared*. The private database's zone + /// changes then ran the shared branch, which seats a "Joining…" placeholder + /// for every newly-visible zone — so every game the user owned got a + /// `databaseScope == 1` row carrying the owner placeholder. + /// + /// Neither scope's `gameIdentityPredicate` can match that row again + /// (private wants `ckZoneOwnerName == NIL`, shared wants a concrete owner), + /// so the arriving Game record forked a second row instead of filling this + /// one in. Two rows then answered to one `id`, and the `fetchLimit = 1` + /// lookups in `GameStore.loadGame(id:)` and `movesDiagnostics(for:by:)` + /// picked between them unpredictably — an empty row reads as + /// `.missingGrid`, surfacing as "Couldn't load puzzle". + /// + /// Only empty placeholders are touched: a stranded row that carries a + /// puzzle is not this bug's work and is left alone. A row with a sibling is + /// merged into it and deleted; a row without one is repaired to private + /// scope, which is always right here — the branch that produced these rows + /// only ever ran over zones the user owns — so the ordinary private sync + /// path adopts and fills it. + /// + /// Gated on a stored flag rather than left to its predicate: unlike the + /// non-destructive backfills below this one deletes rows, and a standing + /// destructive sweep would be a hazard if some later change ever made this + /// row shape legitimate. Remove as one block with + /// `strandedSharedRowHealKey`, its call site, and + /// `PersistenceControllerHealTests`. + private static let strandedSharedRowHealKey = "healStrandedSharedGameRows_v1" + + func healStrandedSharedGameRows_v1(force: Bool = false) { + let defaults = UserDefaults.standard + if !force, defaults.bool(forKey: Self.strandedSharedRowHealKey) { return } + + let ctx = container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let summary: String? = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + // `CKCurrentUserDefaultName` is the spelling the placeholder branch + // copied off the private zone; nil covers the same shape from + // `constructJoinedGame`'s former owner-normalising ternary. + req.predicate = NSPredicate( + format: "databaseScope == 1" + + " AND (ckZoneOwnerName == nil OR ckZoneOwnerName == %@)" + + " AND (puzzleSource == nil OR puzzleSource == %@)", + CKCurrentUserDefaultName, + "" + ) + guard let stranded = try? ctx.fetch(req), !stranded.isEmpty else { return nil } + + var merged = 0 + var repaired = 0 + for row in stranded { + guard let id = row.id else { + // No domain identity, no puzzle, unmatchable by sync: inert. + ctx.delete(row) + merged += 1 + continue + } + let siblings = NSFetchRequest<GameEntity>(entityName: "GameEntity") + siblings.predicate = NSPredicate( + format: "id == %@ AND SELF != %@", id as CVarArg, row + ) + siblings.fetchLimit = 1 + if let survivor = try? ctx.fetch(siblings).first { + Self.adoptChildren(of: row, into: survivor, in: ctx) + ctx.delete(row) + merged += 1 + } else { + row.databaseScope = 0 + row.ckZoneOwnerName = nil + repaired += 1 + } + } + + guard ctx.hasChanges else { return nil } + do { + try ctx.save() + return "PersistenceController: healed \(stranded.count) stranded shared " + + "game row(s) — \(merged) merged, \(repaired) repaired to private" + } catch { + return "PersistenceController: stranded-row heal save failed — \(error)" + } + } + + // `force` is the test affordance for driving the pass repeatedly; it + // deliberately leaves the shared flag untouched so tests stay hermetic. + if !force { defaults.set(true, forKey: Self.strandedSharedRowHealKey) } + if let summary { eventLog?.note(summary) } + } + + /// Moves a stranded row's irreplaceable children onto the surviving row. + /// + /// `moves` and `journal` are the synced/durable payload and `players` + /// carries local read state, so they are reparented — skipping any whose + /// counterpart the survivor already holds, since both rows may have been + /// written from the same records. `cells` and `peerChanges` are derived + /// caches (replay rebuilds one, the next ledger build the other), so they + /// are left to cascade with the deleted row. + private nonisolated static func adoptChildren( + of row: GameEntity, + into survivor: GameEntity, + in ctx: NSManagedObjectContext + ) { + let existingMoves = Set( + ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName) + ) + for child in (row.moves as? Set<MovesEntity>) ?? [] { + guard let name = child.ckRecordName, !existingMoves.contains(name) else { + ctx.delete(child) + continue + } + child.game = survivor + } + + let existingPlayers = Set( + ((survivor.players as? Set<PlayerEntity>) ?? []).compactMap(\.ckRecordName) + ) + for child in (row.players as? Set<PlayerEntity>) ?? [] { + guard let name = child.ckRecordName, !existingPlayers.contains(name) else { + ctx.delete(child) + continue + } + child.game = survivor + } + + // Journal rows have no record name; a device's log is keyed by its + // source device and sequence number. + let existingJournal = Set( + ((survivor.journal as? Set<JournalEntity>) ?? []).map { + "\($0.sourceDeviceID ?? "")|\($0.seq)" + } + ) + for child in (row.journal as? Set<JournalEntity>) ?? [] { + let key = "\(child.sourceDeviceID ?? "")|\(child.seq)" + guard !existingJournal.contains(key) else { + ctx.delete(child) + continue + } + child.game = survivor + } + } + + // MARK: - Backfills + /// One-shot pass for `GameEntity` rows created before cached summary /// fields were wired into the creation paths. Runs off the main thread, /// no-ops on every subsequent launch. diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -569,10 +569,7 @@ actor SyncEngine { /// send in flight per scope so repeated enqueues for the same record /// coalesce instead of racing separate drains against adjacent etags. private func sendChangesDetached(on engine: CKSyncEngine) { - guard let scope = scope(for: engine) else { - Task.detached { [engine] in try? await engine.sendChanges() } - return - } + let scope = scope(for: engine) if sendChangesInFlight.contains(scope) { sendChangesPending.insert(scope) return @@ -584,10 +581,18 @@ actor SyncEngine { } } - private func scope(for engine: CKSyncEngine) -> DatabaseScope? { - if let privateEngine, engine === privateEngine { return .private } - if let sharedEngine, engine === sharedEngine { return .shared } - return nil + /// The database an engine is bound to. Read off the engine itself rather + /// than compared against `privateEngine`/`sharedEngine`: `resetSyncState` + /// (and the account-switch / v4-migration purges that call it) replaces + /// both instances while their fetches are still in flight, and a retired + /// engine matches neither property. Identity comparison then misreports + /// the retired engine's scope — for `handleEvent` it silently resolved + /// *private* events to shared, which minted "Joining…" placeholders for + /// the user's own zones, cross-wrote engine state between scopes, and + /// applied private zone deletions as share revocations. The binding is + /// immutable for an engine's lifetime, so it stays correct after a swap. + private nonisolated func scope(for engine: CKSyncEngine) -> DatabaseScope { + DatabaseScope(isPrivate: engine.database.databaseScope == .private) } private func finishDetachedSendChanges(for scope: DatabaseScope) { @@ -1796,6 +1801,23 @@ actor SyncEngine { let zoneID = mod.zoneID let zoneName = zoneID.zoneName guard zoneName.hasPrefix("game-") else { continue } + // A zone in the shared database always belongs to somebody + // else — CloudKit rejects any own-owner zone there ("Only + // shared zones can be accessed in the shared DB"). An + // own-owner zone reaching this branch therefore means the + // *private* database's changes were routed here, and + // seating a `databaseScope == 1` row for a zone the user + // owns produces a row no code path can ever heal: the + // private Game record that would fill it in matches on + // `ckZoneOwnerName == NIL` and so forks a second row + // instead, leaving this one permanently empty and shadowing + // the real game in every `id`-keyed lookup. + guard zoneID.ownerName != CKCurrentUserDefaultName else { + messages.append( + "refused shared placeholder for own-owner zone \(zoneName)" + ) + continue + } let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) req.fetchLimit = 1 @@ -2989,7 +3011,7 @@ extension SyncEngine: CKSyncEngineDelegate { syncEngine: CKSyncEngine ) async -> CKSyncEngine.FetchChangesOptions { var options = context.options - let isPrivate = syncEngine === privateEngine + let isPrivate = scope(for: syncEngine) == .private var excluded = await completedFetchExclusions(isPrivate: isPrivate) if isPrivate { excluded.insert(Archive.zoneID) } @@ -3013,7 +3035,7 @@ extension SyncEngine: CKSyncEngineDelegate { } func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - let isPrivate = syncEngine === privateEngine + let isPrivate = scope(for: syncEngine) == .private switch event { case .stateUpdate(let e): await saveEngineState(e.stateSerialization, isPrivate: isPrivate) @@ -3069,7 +3091,7 @@ extension SyncEngine: CKSyncEngineDelegate { let pending = engine.state.pendingRecordZoneChanges guard !pending.isEmpty else { return nil } await traceForeignPlayerWrites(in: pending) - let scope: DatabaseScope = engine === sharedEngine ? .shared : .private + let scope = scope(for: engine) let pingSnapshot = pendingPings let decisionSnapshot = pendingDecisionPayloads let decisionVersionSnapshot = pendingDecisionVersions diff --git a/Tests/Unit/Sync/InboundRecordIdentityTests.swift b/Tests/Unit/Sync/InboundRecordIdentityTests.swift @@ -207,4 +207,104 @@ struct InboundRecordIdentityTests { #expect(firstEntity.title == "First") #expect(secondEntity.title == "Second") } + + // MARK: - Own-owner shared rows + + /// The shape that made a puzzle unopenable on a real device: a + /// `databaseScope == 1` row holding the owner placeholder. Both scopes' + /// predicates miss it — private wants `ckZoneOwnerName == NIL`, shared + /// wants a concrete owner — so the arriving Game record forks a second + /// row instead of filling this one in, and the empty row then shadows the + /// real game in every `id`-keyed lookup. Nothing may create it. + @Test("an own-owner shared row is unreachable from either scope's lookup") + func ownOwnerSharedRowIsUnmatchable() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let name = RecordSerializer.recordName(forGameID: gameID) + + let stranded = GameEntity(context: ctx) + stranded.id = gameID + stranded.ckRecordName = name + stranded.ckZoneName = name + stranded.ckZoneOwnerName = CKCurrentUserDefaultName + stranded.databaseScope = 1 + stranded.title = "Joining\u{2026}" + stranded.puzzleSource = "" + stranded.createdAt = .now + stranded.updatedAt = .now + try ctx.save() + + // The private Game record that should have healed it forks instead. + let record = CKRecord( + recordType: "Game", + recordID: CKRecord.ID( + recordName: name, + zoneID: RecordSerializer.zoneID(for: gameID) + ) + ) + record["title"] = "Real Puzzle" as CKRecordValue + let applied = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: .private) + + #expect(applied.objectID != stranded.objectID) + #expect(stranded.puzzleSource == "") + + // Two rows now answer to the same game id — which is what breaks the + // `fetchLimit = 1` lookups in `GameStore.loadGame(id:)`. + let byID = NSFetchRequest<GameEntity>(entityName: "GameEntity") + byID.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + #expect(try ctx.fetch(byID).count == 2) + } + + @Test("constructJoinedGame refuses a zone the current user owns") + func constructJoinedGameRefusesOwnOwnerZone() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ownZone = RecordSerializer.zoneID(for: gameID) + + #expect(throws: GameStore.LoadError.sharedZoneOwnedByCurrentUser) { + try store.constructJoinedGame( + gameID: gameID, + zoneID: ownZone, + source: Self.puzzleSource + ) + } + + let all = NSFetchRequest<GameEntity>(entityName: "GameEntity") + #expect(try persistence.viewContext.fetch(all).isEmpty) + } + + @Test("constructJoinedGame keeps the share owner's concrete record name") + func constructJoinedGameStoresConcreteOwner() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let sharedZone = RecordSerializer.zoneID(for: gameID, ownerName: "_owner") + + try store.constructJoinedGame( + gameID: gameID, + zoneID: sharedZone, + source: Self.puzzleSource + ) + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let entity = try #require(try persistence.viewContext.fetch(req).first) + #expect(entity.databaseScope == 1) + #expect(entity.ckZoneOwnerName == "_owner") + } + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + AB + CD + + + A1. Top row ~ AB + A3. Bottom row ~ CD + + D1. Left column ~ AC + D2. Right column ~ BD + """ } diff --git a/Tests/Unit/Sync/PersistenceControllerHealTests.swift b/Tests/Unit/Sync/PersistenceControllerHealTests.swift @@ -0,0 +1,196 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Covers the TEMPORARY v1.1 heal for `GameEntity` rows stranded at +/// `databaseScope == 1` with the owner placeholder. Delete this file as one +/// block with `PersistenceController.healStrandedSharedGameRows_v1`. +@Suite("Stranded shared game row heal") +@MainActor +struct PersistenceControllerHealTests { + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + AB + CD + + + A1. Top row ~ AB + A3. Bottom row ~ CD + + D1. Left column ~ AC + D2. Right column ~ BD + """ + + /// The shape the mis-scoped placeholder branch left behind: shared scope, + /// owner placeholder, no puzzle. + @discardableResult + private func makeStranded( + id: UUID, + in ctx: NSManagedObjectContext + ) -> GameEntity { + let entity = GameEntity(context: ctx) + entity.id = id + entity.ckRecordName = "game-\(id.uuidString)" + entity.ckZoneName = "game-\(id.uuidString)" + entity.ckZoneOwnerName = CKCurrentUserDefaultName + entity.databaseScope = 1 + entity.title = "Joining\u{2026}" + entity.puzzleSource = "" + entity.createdAt = .now + entity.updatedAt = .now + return entity + } + + @discardableResult + private func makeReal( + id: UUID, + in ctx: NSManagedObjectContext + ) -> GameEntity { + let entity = GameEntity(context: ctx) + entity.id = id + entity.ckRecordName = "game-\(id.uuidString)" + entity.ckZoneName = "game-\(id.uuidString)" + entity.ckZoneOwnerName = nil + entity.databaseScope = 0 + entity.title = "Test Puzzle" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = .now + entity.updatedAt = .now + return entity + } + + @discardableResult + private func makeMoves( + recordName: String, + on game: GameEntity, + in ctx: NSManagedObjectContext + ) -> MovesEntity { + let moves = MovesEntity(context: ctx) + moves.ckRecordName = recordName + moves.authorID = "alice" + moves.deviceID = "devicea" + moves.cells = Data() + moves.updatedAt = .now + moves.game = game + return moves + } + + private func games(in ctx: NSManagedObjectContext, id: UUID) throws -> [GameEntity] { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", id as CVarArg) + return try ctx.fetch(req) + } + + @Test("a stranded row is merged away when the real row exists") + func mergesStrandedRowIntoSibling() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + makeStranded(id: id, in: ctx) + makeReal(id: id, in: ctx) + try ctx.save() + #expect(try games(in: ctx, id: id).count == 2) + + persistence.healStrandedSharedGameRows_v1(force: true) + ctx.refreshAllObjects() + + let survivors = try games(in: ctx, id: id) + #expect(survivors.count == 1) + // The row that survives is the playable one — that is the whole point. + #expect(survivors.first?.puzzleSource == Self.puzzleSource) + #expect(survivors.first?.databaseScope == 0) + } + + @Test("moves parented to the stranded row move across, without duplicating") + func adoptsStrandedChildren() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + let stranded = makeStranded(id: id, in: ctx) + let real = makeReal(id: id, in: ctx) + // One row only the stranded parent holds, and one both hold — the + // latter is what a doubly-applied Moves record leaves behind. + makeMoves(recordName: "moves-\(id.uuidString)-alice-devicea", on: stranded, in: ctx) + makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: stranded, in: ctx) + makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: real, in: ctx) + try ctx.save() + + persistence.healStrandedSharedGameRows_v1(force: true) + ctx.refreshAllObjects() + + let survivor = try #require(try games(in: ctx, id: id).first) + let names = ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName) + #expect(Set(names) == [ + "moves-\(id.uuidString)-alice-devicea", + "moves-\(id.uuidString)-bob-deviceb", + ]) + // The already-present row is not duplicated by the adoption. + #expect(names.count == 2) + + let allMoves = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + #expect(try ctx.fetch(allMoves).count == 2) + } + + @Test("a stranded row with no sibling is repaired, never deleted") + func repairsLoneStrandedRow() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + makeStranded(id: id, in: ctx) + try ctx.save() + + persistence.healStrandedSharedGameRows_v1(force: true) + ctx.refreshAllObjects() + + // Deleting would lose the only row for a game the user owns; repairing + // it to private scope lets the ordinary sync path fill it in. + let rows = try games(in: ctx, id: id) + #expect(rows.count == 1) + #expect(rows.first?.databaseScope == 0) + #expect(rows.first?.ckZoneOwnerName == nil) + } + + @Test("a shared row that carries a puzzle is left alone") + func leavesPopulatedSharedRowsAlone() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + // Same stranded owner spelling, but with real content: not this bug's + // work, and scope 0 would wrongly claim ownership of a joined game. + let joined = makeStranded(id: id, in: ctx) + joined.title = "Test Puzzle" + joined.puzzleSource = Self.puzzleSource + try ctx.save() + + persistence.healStrandedSharedGameRows_v1(force: true) + ctx.refreshAllObjects() + + let rows = try games(in: ctx, id: id) + #expect(rows.count == 1) + #expect(rows.first?.databaseScope == 1) + #expect(rows.first?.ckZoneOwnerName == CKCurrentUserDefaultName) + } + + @Test("a healthy store is untouched") + func healthyStoreIsUntouched() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let id = UUID() + makeReal(id: id, in: ctx) + try ctx.save() + + persistence.healStrandedSharedGameRows_v1(force: true) + ctx.refreshAllObjects() + + let rows = try games(in: ctx, id: id) + #expect(rows.count == 1) + #expect(rows.first?.puzzleSource == Self.puzzleSource) + } +}