crossmate

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

commit 11effb7e619b71a9199f63901cd33c6de6f5555a
parent 4812ac00c10eca1ff213948a6bf216a920b245dc
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 22:56:41 +0900

Cache game replays in Core Data

Opening a finished game re-ran fetchReplay's two live CloudKit queries
every time: PuzzleView holds the ReplayController as fresh @State, so
each entry started from .idle and the Success Panel's load re-queried the
zone for the Moves keys and every contributor's Journal asset, flashing
'Loading replay…' on each open. Nothing was kept between visits even
though a completed game's journals are frozen by edit-lockout and can
never change.

Other devices' journals are now persisted on first complete fetch and
re-merged from the local store on later opens, with no CloudKit round
trip. Rather than a parallel store, they unify into the existing
JournalEntity table: two new optional attributes, sourceAuthorID and
sourceDeviceID, tag each row's origin device. A nil source means this
device's own log — the write path and existing rows are untouched, so a
nil row reads as ours — while a set source marks a row cached from
another device for replay. A new GameEntity.replayCacheComplete flag
gates whether that cache is trustworthy; it is set only once a fetch
assembles a strictly-complete .ready timeline, which for a finished game
is final.

Because remote rows now share JournalEntity, the two local-only readers
filter sourceDeviceID == nil so foreign rows cannot leak in:
MovesJournal.load, whose seq / prevSeqAtCell links drive undo/redo, and
RecordBuilder's journal-upload reconstruction, which would otherwise
re-upload other players' moves as our own. The value-to-row mapping is
extracted to MovesJournal.assign so the local write path and the cache
write path can't drift.

The schema change is additive — lightweight migration, no CloudKit
dashboard step. ReplayCacheTests covers the round trip, the solo and
re-cache cases and the invariant that cached remote rows stay out of
this device's own log.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 3+++
MCrossmate/Persistence/GameStore.swift | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Persistence/Journal.swift | 43++++++++++++++++++++++++++++---------------
MCrossmate/Services/AppServices.swift | 56++++++++++++++++++++++++++++++++++++++++++++++----------
MCrossmate/Sync/RecordBuilder.swift | 12+++++++-----
ATests/Unit/ReplayCacheTests.swift | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 308 insertions(+), 30 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; + 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; @@ -236,6 +237,7 @@ 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; }; 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; + 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; }; 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; }; 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; @@ -413,6 +415,7 @@ C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, + 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */, A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */, 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, @@ -764,6 +767,7 @@ 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, + 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */, 9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */, AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */, 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -27,6 +27,7 @@ <attribute name="hasPushPending" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="puzzleCmVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="puzzleSource" attributeType="String"/> + <attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/> @@ -133,6 +134,8 @@ <attribute name="prevSeqAtCell" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="seq" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="sourceAuthorID" optional="YES" attributeType="String"/> + <attribute name="sourceDeviceID" optional="YES" attributeType="String"/> <attribute name="targetSeq" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="timestamp" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="journal" inverseEntity="GameEntity"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -724,6 +724,80 @@ final class GameStore { movesJournal.recordedEntries(gameID: gameID) } + /// Other devices' journals cached locally for replay, grouped by source + /// device — or `nil` if this game's cache isn't known-complete yet + /// (`replayCacheComplete`), in which case the caller must fetch from + /// CloudKit. These are stored as `JournalEntity` rows carrying a source key + /// (`sourceDeviceID != nil`), kept out of this device's own log. A finished + /// game's journals never change, so once cached they replay offline; the + /// live local journal is overlaid separately by `localReplaySource`, so this + /// deliberately excludes any cached copy of ourselves and may be empty (a + /// solo game has no remote contributors but is still "complete"). + func cachedRemoteJournals(forGameID gameID: UUID) async -> [DeviceJournal]? { + let ctx = persistence.container.newBackgroundContext() + return await ctx.perform { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gameReq).first, game.replayCacheComplete else { + return nil + } + let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") + req.predicate = NSPredicate( + format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg + ) + req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] + let rows = (try? ctx.fetch(req)) ?? [] + var byDevice: [JournalDeviceKey: [JournalValue]] = [:] + for row in rows { + let key = JournalDeviceKey( + authorID: row.sourceAuthorID ?? "", + deviceID: row.sourceDeviceID ?? "" + ) + byDevice[key, default: []].append(MovesJournal.value(from: row)) + } + return byDevice.map { DeviceJournal(key: $0.key, entries: $0.value) } + } + } + + /// Persists `journals` (other devices' logs) as this game's replay cache and + /// marks it `replayCacheComplete`, so later opens replay from Core Data with + /// no CloudKit round-trip. Safe because a completed game's journals are + /// frozen by edit-lockout. Idempotent: replaces any existing cached rows for + /// the game. Rows carry a source key so the local-log readers skip them. + func cacheRemoteJournals(_ journals: [DeviceJournal], forGameID gameID: UUID) async { + let ctx = persistence.container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + await ctx.perform { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gameReq).first else { return } + + let stale = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") + stale.predicate = NSPredicate( + format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg + ) + for row in (try? ctx.fetch(stale)) ?? [] { ctx.delete(row) } + + for journal in journals { + for value in journal.entries { + let row = JournalEntity(context: ctx) + MovesJournal.assign(value, to: row, gameID: gameID) + row.sourceAuthorID = journal.key.authorID + row.sourceDeviceID = journal.key.deviceID + row.game = game + } + } + game.replayCacheComplete = true + do { + try ctx.save() + } catch { + self.eventLog?.note("GameStore: replay cache save failed — \(error)", level: "error") + } + } + } + // MARK: - Engagement room /// `true` once `gameID` has been completed (solved or resigned). A diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift @@ -337,7 +337,10 @@ final class MovesJournal { let ctx = backgroundContext let loaded: [JournalValue] = ctx.performAndWait { let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") - req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + // `sourceDeviceID == nil` keeps this to *this device's* log: rows + // cached from other devices for replay (see GameStore's replay + // cache) carry a source key and must not enter the undo/redo model. + req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg) req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] let rows = (try? ctx.fetch(req)) ?? [] return rows.map(Self.value(from:)) @@ -364,20 +367,8 @@ final class MovesJournal { let ctx = backgroundContext ctx.perform { let entity = JournalEntity(context: ctx) - entity.gameID = gameID - entity.seq = value.seq - entity.timestamp = value.timestamp - entity.row = Int16(value.position.row) - entity.col = Int16(value.position.col) - entity.letter = value.state.letter - entity.markCode = CellMarkCodec.code(value.state.mark) - entity.cellAuthorID = value.state.cellAuthorID - entity.actingAuthorID = value.actingAuthorID - entity.kind = value.kind.rawValue - entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) } - entity.batchID = value.batchID - entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) } - entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) } + // Local log: leave the source key nil (this device's own row). + Self.assign(value, to: entity, gameID: gameID) let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -393,6 +384,28 @@ final class MovesJournal { } } + /// Writes a `JournalValue`'s fields onto a `JournalEntity`, leaving the + /// `game` relationship and the `sourceAuthorID`/`sourceDeviceID` key to the + /// caller. Shared by the local-log `persist` and the replay cache (which + /// also stamps the source device), so the value→row mapping lives in one + /// place. `nonisolated` so the cache can call it on its own context. + nonisolated static func assign(_ value: JournalValue, to entity: JournalEntity, gameID: UUID) { + entity.gameID = gameID + entity.seq = value.seq + entity.timestamp = value.timestamp + entity.row = Int16(value.position.row) + entity.col = Int16(value.position.col) + entity.letter = value.state.letter + entity.markCode = CellMarkCodec.code(value.state.mark) + entity.cellAuthorID = value.state.cellAuthorID + entity.actingAuthorID = value.actingAuthorID + entity.kind = value.kind.rawValue + entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) } + entity.batchID = value.batchID + entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) } + entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) } + } + /// Maps a persisted row to its in-memory value. `internal` (not `private`) /// so `RecordBuilder` can reconstruct the upload asset straight from Core /// Data on its own background context. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -2740,6 +2740,40 @@ final class AppServices { /// from the UI when the finish banner appears. func loadReplay(gameID: UUID) async -> JournalReplayResult { let short = gameID.uuidString.prefix(8) + func describe(_ result: JournalReplayResult) -> String { + switch result { + case .ready(let timeline): return "ready(steps=\(timeline.count))" + case .waiting(let missing): return "waiting(missing=\(missing))" + case .unavailable: return "unavailable" + } + } + // This device's live journal is always overlaid (fresher than any + // uploaded copy of itself), whether the contributors' journals come + // from the local cache or a fresh CloudKit fetch. + let local = store.localReplaySource(gameID: gameID) + let localKey = local?.key ?? JournalDeviceKey(authorID: "", deviceID: "") + let localEntries = local?.entries ?? [] + + // Completed-game journals are frozen (edit-lockout), so once every + // contributor's journal has been fetched in full we cache the remote + // ones locally and re-merge from Core Data — no CloudKit round-trip on + // re-entry. The cache is `nil` until that first complete fetch lands. + if let cachedRemotes = await store.cachedRemoteJournals(forGameID: gameID) { + let result = ReplayAssembler.assemble( + fetch: JournalReplayFetch( + journals: cachedRemotes, + expectedDevices: Set(cachedRemotes.map(\.key)) + ), + localKey: localKey, + localEntries: localEntries + ) + syncMonitor.note( + "replay[\(short)]: served from cache — remoteDevices=\(cachedRemotes.count), " + + "localEntries=\(localEntries.count) → \(describe(result))" + ) + return result + } + // A nil return means zone unknown / access revoked; a throw means the // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but // log which one (and the error) so the diagnostics stream can tell them @@ -2759,22 +2793,24 @@ final class AppServices { syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)") return .unavailable } - let local = store.localReplaySource(gameID: gameID) let result = ReplayAssembler.assemble( fetch: fetch, - localKey: local?.key ?? JournalDeviceKey(authorID: "", deviceID: ""), - localEntries: local?.entries ?? [] + localKey: localKey, + localEntries: localEntries ) - let resultDesc: String - switch result { - case .ready(let timeline): resultDesc = "ready(steps=\(timeline.count))" - case .waiting(let missing): resultDesc = "waiting(missing=\(missing))" - case .unavailable: resultDesc = "unavailable" + // A complete merge will never change (the game is finished), so cache + // the *remote* journals for offline re-entry. Our own copy is excluded: + // the live local journal is overlaid fresh on every load. + if case .ready = result { + await store.cacheRemoteJournals( + fetch.journals.filter { $0.key != localKey }, + forGameID: gameID + ) } syncMonitor.note( "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " + - "journals=\(fetch.journals.count), localEntries=\(local?.entries.count ?? 0) " + - "→ \(resultDesc)" + "journals=\(fetch.journals.count), localEntries=\(localEntries.count) " + + "→ \(describe(result))" ) return result } diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -70,15 +70,17 @@ extension SyncEngine { systemFields: entity.ckSystemFields ) } else if name.hasPrefix("journal-") { - // Reconstruct the upload asset from the durable local journal - // (every JournalEntity row for this game belongs to this - // device). Reading Core Data — not an in-memory stash — means a - // pending journal save survives an app kill, like Moves/Player. + // Reconstruct the upload asset from the durable local journal. + // `sourceDeviceID == nil` excludes rows cached from other + // devices for replay (those carry a source key) so we upload + // only this device's own log. Reading Core Data — not an + // in-memory stash — means a pending journal save survives an + // app kill, like Moves/Player. guard let (gameID, authorID, deviceID) = RecordSerializer.parseJournalRecordName(name) else { return nil } let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") - req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg) req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] guard let rows = try? ctx.fetch(req), !rows.isEmpty else { return nil } let entries = rows.map(MovesJournal.value(from:)) diff --git a/Tests/Unit/ReplayCacheTests.swift b/Tests/Unit/ReplayCacheTests.swift @@ -0,0 +1,146 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// `GameStore`'s on-device replay cache: once a finished game's remote journals +/// are fetched in full they are stored as `JournalEntity` rows carrying a source +/// device key, so re-opening the game replays from Core Data without a CloudKit +/// round-trip. These rows must round-trip faithfully *and* stay out of this +/// device's own log (undo/redo and the journal upload read the same table). +@Suite("Replay cache", .serialized) +@MainActor +struct ReplayCacheTests { + + private func makeGame(in persistence: PersistenceController) -> UUID { + let context = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = "Cached Game" + entity.puzzleSource = "Title: Cached\n\n\nAB\n\n\nA1. _ ~ AB" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + try? context.save() + return gameID + } + + private func remoteJournal( + author: String, + device: String, + letters: [(Int64, String)] + ) -> DeviceJournal { + let entries = letters.map { seq, letter in + JournalValue( + seq: seq, + timestamp: Date(timeIntervalSince1970: TimeInterval(seq)), + position: GridPosition(row: 0, col: Int(seq)), + state: JournalCellState(letter: letter, mark: .none, cellAuthorID: author), + actingAuthorID: author, + kind: .input, + targetSeq: nil, + batchID: nil, + prevSeqAtCell: nil, + direction: nil + ) + } + return DeviceJournal( + key: JournalDeviceKey(authorID: author, deviceID: device), + entries: entries + ) + } + + @Test("cache is nil until a complete fetch is stored") + func cacheEmptyBeforeStore() async { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = makeGame(in: persistence) + + #expect(await store.cachedRemoteJournals(forGameID: gameID) == nil) + } + + @Test("stored remote journals round-trip, grouped by source device") + func storedJournalsRoundTrip() async { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = makeGame(in: persistence) + + let a = remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A"), (1, "B")]) + let b = remoteJournal(author: "bob", device: "dev-b", letters: [(0, "C")]) + await store.cacheRemoteJournals([a, b], forGameID: gameID) + + let cached = await store.cachedRemoteJournals(forGameID: gameID) + let byKey = Dictionary( + uniqueKeysWithValues: (cached ?? []).map { ($0.key, $0.entries) } + ) + #expect(cached?.count == 2) + #expect(byKey[a.key]?.map(\.state.letter) == ["A", "B"]) + #expect(byKey[b.key]?.map(\.state.letter) == ["C"]) + } + + @Test("solo game with no remote contributors still caches as complete") + func soloGameCachesAsComplete() async { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = makeGame(in: persistence) + + await store.cacheRemoteJournals([], forGameID: gameID) + + // Non-nil (complete), but empty — distinct from the un-cached nil. + let cached = await store.cachedRemoteJournals(forGameID: gameID) + #expect(cached != nil) + #expect(cached?.isEmpty == true) + } + + @Test("re-caching replaces prior remote rows") + func reCacheReplaces() async { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = makeGame(in: persistence) + + await store.cacheRemoteJournals( + [remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A")])], + forGameID: gameID + ) + await store.cacheRemoteJournals( + [remoteJournal(author: "bob", device: "dev-b", letters: [(0, "C")])], + forGameID: gameID + ) + + let cached = await store.cachedRemoteJournals(forGameID: gameID) + #expect(cached?.count == 1) + #expect(cached?.first?.key.authorID == "bob") + } + + @Test("cached remote rows stay out of this device's own journal") + func remoteRowsExcludedFromLocalLog() async { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = makeGame(in: persistence) + + // Seed one *local* entry (no source key) through the real write path. + let localJournal = MovesJournal(persistence: persistence) + _ = localJournal.record( + gameID: gameID, + position: GridPosition(row: 0, col: 0), + state: JournalCellState(letter: "L", mark: .none, cellAuthorID: "me"), + actingAuthorID: "me", + kind: .input, + targetSeq: nil, + batchID: nil + ) + await localJournal.flush() + + // Cache a remote device's journal into the same table. + await store.cacheRemoteJournals( + [remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A"), (1, "B")])], + forGameID: gameID + ) + + // The local-log reader sees only the local row, never the cached remotes. + let local = store.localJournalEntries(for: gameID) + #expect(local.map(\.state.letter) == ["L"]) + } +}