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:
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"])
+ }
+}