crossmate

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

commit d56b203e96ab2c3a4574dd5a6a78d03d70f2c6a3
parent 6c5e01a904c300003688a51cbd304622d9b0851d
Author: Michael Camilleri <[email protected]>
Date:   Sat, 25 Jul 2026 07:54:21 +0900

Refresh provisional Chronicles when journals arrive

A provisional Chronicle could remain waiting after its missing device
journal reached CloudKit because the inbound Journal path refreshed only
the open Success Panel. Rewriting the Chronicle then waited for a later
cold-launch reconciliation.

This commit routes the affected game IDs to GameArchiver as soon as
Journal records land. The archiver merges the late journals and rewrites
the existing Chronicle immediately, while preserving the Success Panel's
live replay refresh.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 6++++++
MCrossmate/Sync/RecordApplier.swift | 2+-
MCrossmate/Sync/SyncEngine.swift | 30++++++++++++++++++++++--------
MTests/Unit/JournalUploadTests.swift | 16++++++++++++++++
4 files changed, 45 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1258,6 +1258,12 @@ final class AppServices { await gameArchiver.archiveIfNeeded(gameID: gameID) } + await syncEngine.setOnReplayJournalsSynced { [gameArchiver] gameIDs in + for gameID in gameIDs { + await gameArchiver.archiveIfNeeded(gameID: gameID) + } + } + await syncEngine.setOnCompletionRecordsSaved { [weak self] records in await self?.sessions.noteCompletionRecordsSaved(records) } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -44,7 +44,7 @@ struct BatchEffects { /// local completion path. var completedTransitions = Set<UUID>() /// Games for which an inbound `Journal` record landed — wakes a waiting - /// finish-banner replay to re-check completeness. + /// finish-banner replay and refreshes any provisional Chronicle. var journalsSynced = Set<UUID>() /// Account-level push address decisions seen in the private account zone. var accountPushAddresses: [String] = [] diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -286,6 +286,10 @@ actor SyncEngine { /// App-level side effects that are not sync-engine state (for example /// closing public share tickets) hang off this edge. var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)? + /// Fires when late contributor journals land so provisional Chronicles can + /// be rebuilt without waiting for the next cold-launch reconciliation. + private var onReplayJournalsSynced: + (@MainActor @Sendable (Set<UUID>) async -> Void)? private var onCompletionRecordsSaved: (@MainActor @Sendable ([UUID: Set<CompletionDurableRecordKind>]) async -> Void)? /// Fires with the game ID of a shared zone that just appeared locally — /// the user joined the game here or on a sibling device. Drives cleanup @@ -392,6 +396,12 @@ actor SyncEngine { onGameCompleted = cb } + func setOnReplayJournalsSynced( + _ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void + ) { + onReplayJournalsSynced = cb + } + func setOnCompletionRecordsSaved( _ cb: @MainActor @Sendable @escaping ([UUID: Set<CompletionDurableRecordKind>]) async -> Void ) { @@ -1973,7 +1983,8 @@ actor SyncEngine { // delegate — the replay loader (`fetchReplay`) pulls them on // demand with a plain CKQuery. But the record landing is the // signal a contributor finished and uploaded, so note the - // game to wake a waiting replay banner (posted below). + // game to wake a waiting replay banner and refresh its + // provisional Chronicle (dispatched below). if let (gid, _, _) = RecordSerializer.parseJournalRecordName( record.recordID.recordName ) { @@ -2161,15 +2172,18 @@ actor SyncEngine { userInfo: ["gameIDs": effects.rosterRelevant] ) } - if !effects.journalsSynced.isEmpty { - NotificationCenter.default.post( - name: .replayJournalDidSync, - object: nil, - userInfo: ["gameIDs": effects.journalsSynced] - ) - } + await notifyReplayJournalsSynced(effects.journalsSynced) } + func notifyReplayJournalsSynced(_ gameIDs: Set<UUID>) async { + guard !gameIDs.isEmpty else { return } + NotificationCenter.default.post( + name: .replayJournalDidSync, + object: nil, + userInfo: ["gameIDs": gameIDs] + ) + await onReplayJournalsSynced?(gameIDs) + } private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String { counts diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift @@ -305,4 +305,20 @@ struct JournalUploadEngineTests { let after = await engine.pendingSaveRecordNames(scope: .private) #expect(!after.contains(journalName)) } + + @Test("late journals invoke Chronicle reconciliation") + func lateJournalInvokesChronicleReconciliation() async { + let persistence = makeTestPersistence() + let engine = await makeEngine(persistence: persistence) + let first = UUID() + let second = UUID() + var reconciled = Set<UUID>() + + await engine.setOnReplayJournalsSynced { gameIDs in + reconciled = gameIDs + } + await engine.notifyReplayJournalsSynced([first, second]) + + #expect(reconciled == [first, second]) + } }