commit 39e4b818fd13cc6f1aae8132c6fc5f670a3fcb1e
parent 31edc41ec90f4a21cf3171c787db4d8dccc013b8
Author: Michael Camilleri <[email protected]>
Date: Sat, 25 Jul 2026 00:17:53 +0900
Show replay waits in provisional Chronicles
A Chronicle created before every device journal arrived appeared to have
lost its replay permanently, even though the live zone was still
retained and reconciliation would continue retrying.
This commit advances the Chronicle payload to format 3 and distinguishes
available, waiting and retention-expired replay states. Materialised
Chronicles retain the missing-device count so the Success Panel shows
its existing waiting treatment, while only the 14-day fallback reports
the replay as unavailable. Earlier payload formats remain readable and
complete reconciliation upgrades a waiting projection in place.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
6 files changed, 130 insertions(+), 40 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -36,6 +36,7 @@
<attribute name="puzzleParserVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" renamingIdentifier="puzzleCmVersion" usesScalarValueType="YES"/>
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="replayMissingDeviceCount" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<attribute name="replayUnavailable" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="shareParticipants" optional="YES" attributeType="String"/>
<attribute name="syncVersion" optional="YES" attributeType="Integer 64" defaultValueString="1" usesScalarValueType="YES"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1636,17 +1636,21 @@ final class GameStore {
movesJournal.recordedEntries(gameID: gameID)
}
- /// Deadline-retired owner archives intentionally omit journals. This is a
- /// durable product state, not a transient CloudKit failure, so replay must
- /// fail immediately instead of querying the archive zone and interpreting
- /// its lack of live Journal records as an empty replay.
- func isReplayUnavailable(forGameID gameID: UUID) async -> Bool {
+ /// A Chronicle with no embedded journals is either still waiting for its
+ /// live zone to collect every device's history, or is the terminal fallback
+ /// written when that retry window expires.
+ func archivedReplayBlocker(forGameID gameID: UUID) async -> JournalReplayResult? {
let ctx = persistence.container.newBackgroundContext()
return await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
- return (try? ctx.fetch(req).first)?.replayUnavailable == true
+ guard let game = try? ctx.fetch(req).first else { return nil }
+ if game.replayUnavailable { return .unavailable }
+ if let missing = game.replayMissingDeviceCount?.intValue, missing > 0 {
+ return .waiting(missing: missing)
+ }
+ return nil
}
}
diff --git a/Crossmate/Services/ReplayLoader.swift b/Crossmate/Services/ReplayLoader.swift
@@ -51,9 +51,18 @@ final class ReplayLoader {
case .unavailable: return "unavailable"
}
}
- if await store.isReplayUnavailable(forGameID: gameID) {
- syncMonitor.note("replay[\(short)]: unavailable by archive retention policy")
- return .unavailable
+ if let blocker = await store.archivedReplayBlocker(forGameID: gameID) {
+ switch blocker {
+ case .waiting(let missing):
+ syncMonitor.note(
+ "replay[\(short)]: Chronicle waiting for \(missing) device journal(s)"
+ )
+ case .unavailable:
+ syncMonitor.note("replay[\(short)]: unavailable by archive retention policy")
+ case .ready:
+ assertionFailure("A ready replay is not a blocker")
+ }
+ return blocker
}
// This device's live journal is always overlaid (fresher than any
// uploaded copy of itself), whether the contributors' journals come
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -55,7 +55,7 @@ enum Archive {
/// preventing a hostile envelope from requesting an arbitrary allocation.
static let maxPayloadAssetBytes = 12_582_912
static let maxDecodedPayloadBytes = 16_777_216
- static let currentPayloadFormatVersion = 2
+ static let currentPayloadFormatVersion = 3
private static let maxParticipantCount = 64
/// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the
@@ -214,6 +214,9 @@ enum Archive {
let completedBy: String?
let solveSeconds: Int
let replayAvailable: Bool
+ /// Added in format 3. A positive value distinguishes a provisional
+ /// Chronicle from the terminal no-replay retention fallback.
+ let replayMissingDeviceCount: Int?
let cells: [Cell]
let journals: [DeviceJournalWire]
/// Added in format 2. Optional so already-written format-1 payloads
@@ -555,7 +558,7 @@ enum Archive {
static func recordPackage(
from snapshot: Snapshot,
- replayAvailable: Bool = true,
+ replayState: ReplayState = .available,
formatVersion: Int = currentPayloadFormatVersion
) throws -> RecordPackage {
let zone = zoneID
@@ -565,6 +568,7 @@ enum Archive {
)
let record = CKRecord(recordType: recordType, recordID: recordID)
+ let replayAvailable = replayState == .available
let blob = Blob(
formatVersion: formatVersion,
originalGameID: snapshot.originalGameID,
@@ -575,6 +579,12 @@ enum Archive {
completedBy: snapshot.completedBy,
solveSeconds: snapshot.solveSeconds,
replayAvailable: replayAvailable,
+ replayMissingDeviceCount: {
+ guard formatVersion >= 3,
+ case .waiting(let missing) = replayState
+ else { return nil }
+ return missing
+ }(),
cells: snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) },
journals: replayAvailable ? try journalWire(snapshot.journal) : [],
wasShared: formatVersion >= 2 ? snapshot.wasShared : nil,
@@ -612,22 +622,30 @@ enum Archive {
/// The frozen solve time in whole seconds, or `nil` for archives written
/// before the field existed (their materialised game simply shows no time).
let solveSeconds: Int?
- let replayAvailable: Bool
+ let replayState: ReplayState
+ var replayAvailable: Bool { replayState == .available }
let wasShared: Bool
let participants: [Participant]
let cells: [Cell]
let journal: [DeviceJournal]
}
+ enum ReplayState: Equatable {
+ case available
+ case waiting(missing: Int)
+ case unavailable
+ }
+
/// Builds the materialization payload directly from a local snapshot,
/// without round-tripping through CloudKit. Used to promote the archive on
/// revocation while still offline — the local game data is fully present, so
/// the cloud copy need not have landed back.
static func payload(
from snapshot: Snapshot,
- replayAvailable: Bool = true
+ replayState: ReplayState = .available
) -> Payload {
- Payload(
+ let replayAvailable = replayState == .available
+ return Payload(
formatVersion: currentPayloadFormatVersion,
originalGameID: snapshot.originalGameID,
archiveGameID: archiveGameID(for: snapshot.originalGameID),
@@ -636,7 +654,7 @@ enum Archive {
completedAt: snapshot.completedAt,
completedBy: snapshot.completedBy,
solveSeconds: snapshot.solveSeconds,
- replayAvailable: replayAvailable,
+ replayState: replayState,
wasShared: snapshot.wasShared,
participants: snapshot.participants,
cells: snapshot.cells,
@@ -698,6 +716,17 @@ enum Archive {
throw LimitError.tooManyCells(count: blob.cells.count)
}
let journals = blob.replayAvailable ? try decodeJournals(blob.journals) : []
+ let replayState: ReplayState
+ if blob.replayAvailable {
+ replayState = .available
+ } else if let missing = blob.replayMissingDeviceCount {
+ guard (1...maxJournalDeviceCount).contains(missing) else {
+ throw PayloadError.identityMismatch
+ }
+ replayState = .waiting(missing: missing)
+ } else {
+ replayState = .unavailable
+ }
let participants = try validatedParticipants(
blob.participants ?? inferredParticipants(
journals: journals,
@@ -714,7 +743,7 @@ enum Archive {
completedAt: blob.completedAt,
completedBy: blob.completedBy,
solveSeconds: blob.solveSeconds,
- replayAvailable: blob.replayAvailable,
+ replayState: replayState,
wasShared: blob.wasShared
?? (Set(participants.map(\.authorID)).count > 1),
participants: participants,
@@ -798,7 +827,7 @@ enum Archive {
completedAt: completedAt,
completedBy: record["completedBy"] as? String,
solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init),
- replayAvailable: true,
+ replayState: .available,
wasShared: Set(journal.map(\.key.authorID).filter { !$0.isEmpty }).count > 1,
participants: participants,
cells: cells,
@@ -870,10 +899,20 @@ enum Archive {
? payload.participants.map(\.authorID).sorted().joined(separator: ",")
: nil
entity.isHidden = false
- // A deadline fallback deliberately carries no journal. Mark it
- // unavailable rather than presenting an authoritative-looking empty or
- // partial replay.
- entity.replayUnavailable = !payload.replayAvailable
+ // Pending Chronicles carry no partial journal, but remain visibly
+ // retryable until reconciliation either captures every device or the
+ // retention deadline turns them into a terminal no-replay fallback.
+ switch payload.replayState {
+ case .available:
+ entity.replayMissingDeviceCount = nil
+ entity.replayUnavailable = false
+ case .waiting(let missing):
+ entity.replayMissingDeviceCount = NSNumber(value: missing)
+ entity.replayUnavailable = false
+ case .unavailable:
+ entity.replayMissingDeviceCount = nil
+ entity.replayUnavailable = true
+ }
entity.replayCacheComplete = payload.replayAvailable
for cell in payload.cells {
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -234,20 +234,33 @@ final class GameArchiver {
if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) }
let present = Set(snapshot.journal.map(\.key))
- let fetchedComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false
+ let fetchedMissing = fetch.map {
+ $0.expectedDevices.subtracting(present).count
+ }
+ let fetchedComplete = fetchedMissing == 0
// Only a compact payload's explicit flag is authoritative. Legacy
// archives used archivedAt for both completeness and a timed best-effort
// fallback, so they must be checked against the still-live zone again.
let complete = storedComplete || fetchedComplete
+ let replayState: Archive.ReplayState
+ if complete {
+ replayState = .available
+ } else if case .waiting(let storedMissing) = stored?.payload.replayState {
+ replayState = .waiting(missing: fetchedMissing ?? storedMissing)
+ } else {
+ // A failed first fetch cannot determine the exact count yet, but it
+ // is still retryable rather than a retention fallback.
+ replayState = .waiting(missing: fetchedMissing ?? 1)
+ }
let storedKeys = Set(stored?.payload.journal.map(\.key) ?? [])
let needsWrite = stored == nil
|| stored?.isLegacy == true
|| stored?.payload.formatVersion != Archive.currentPayloadFormatVersion
- || stored?.payload.replayAvailable != complete
+ || stored?.payload.replayState != replayState
|| !present.isSubset(of: storedKeys)
if needsWrite {
- guard await write(snapshot, replayAvailable: complete) else { return nil }
+ guard await write(snapshot, replayState: replayState) else { return nil }
}
if stored?.isLegacy == true {
@@ -296,9 +309,12 @@ final class GameArchiver {
"archive \(snapshot.originalGameID.uuidString.prefix(8)): " +
"retention deadline reached; retiring without replay"
)
- guard await write(snapshot, replayAvailable: false) else { return }
+ guard await write(snapshot, replayState: .unavailable) else { return }
}
- guard await promoteOwnedBeforeRetirement(snapshot, replayAvailable: keepReplay) else {
+ guard await promoteOwnedBeforeRetirement(
+ snapshot,
+ replayState: keepReplay ? .available : .unavailable
+ ) else {
return
}
await syncEngine.enqueueRetireOwnedGameZone(local.liveZoneID)
@@ -306,11 +322,11 @@ final class GameArchiver {
private func promoteOwnedBeforeRetirement(
_ snapshot: Archive.Snapshot,
- replayAvailable: Bool
+ replayState: Archive.ReplayState
) async -> Bool {
let ctx = persistence.container.newBackgroundContext()
return await ctx.perform {
- let payload = Archive.payload(from: snapshot, replayAvailable: replayAvailable)
+ let payload = Archive.payload(from: snapshot, replayState: replayState)
guard Archive.materialize(payload, in: ctx) != nil else { return false }
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
@@ -389,7 +405,7 @@ final class GameArchiver {
}
guard let local else { return }
let payload = await fetchArchive(originalGameID: gameID)?.payload
- ?? Archive.payload(from: local, replayAvailable: false)
+ ?? Archive.payload(from: local, replayState: .unavailable)
let promoteCtx = persistence.container.newBackgroundContext()
await promoteCtx.perform {
guard Archive.materialize(payload, in: promoteCtx) != nil else { return }
@@ -424,7 +440,7 @@ final class GameArchiver {
)
}
guard let snapshot,
- await write(snapshot, replayAvailable: true)
+ await write(snapshot, replayState: .available)
else { continue }
await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -753,13 +769,13 @@ final class GameArchiver {
@discardableResult
private func write(
_ snapshot: Archive.Snapshot,
- replayAvailable: Bool
+ replayState: Archive.ReplayState
) async -> Bool {
do {
try await ensureArchiveZone()
let package = try Archive.recordPackage(
from: snapshot,
- replayAvailable: replayAvailable
+ replayState: replayState
)
defer { removeTemporaryArchiveFiles(package.temporaryAssetFileURLs) }
try await save(package.record)
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -353,10 +353,10 @@ struct ArchiveTests {
}
}
- @Test("a replay-disabled archive omits journals and preserves the final state")
- func replayDisabledPayload() throws {
+ @Test("a retention fallback omits journals and preserves the final state")
+ func replayUnavailablePayload() throws {
let snapshot = sampleSnapshot(originalGameID: UUID())
- let package = try Archive.recordPackage(from: snapshot, replayAvailable: false)
+ let package = try Archive.recordPackage(from: snapshot, replayState: .unavailable)
defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
let payload = try #require(Archive.payload(from: package.record))
#expect(!payload.replayAvailable)
@@ -369,6 +369,25 @@ struct ArchiveTests {
#expect(!game.replayCacheComplete)
}
+ @Test("a provisional Chronicle waits for missing device journals")
+ func replayPendingPayload() throws {
+ let snapshot = sampleSnapshot(originalGameID: UUID())
+ let package = try Archive.recordPackage(
+ from: snapshot,
+ replayState: .waiting(missing: 2)
+ )
+ defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
+ let payload = try #require(Archive.payload(from: package.record))
+ #expect(payload.replayState == .waiting(missing: 2))
+ #expect(payload.journal.isEmpty)
+
+ let persistence = makeTestPersistence()
+ let game = try #require(Archive.materialize(payload, in: persistence.viewContext))
+ #expect(!game.replayUnavailable)
+ #expect(game.replayMissingDeviceCount?.intValue == 2)
+ #expect(!game.replayCacheComplete)
+ }
+
@Test("the archive payload is compressed")
func payloadIsCompressed() throws {
let base = sampleSnapshot(originalGameID: UUID())
@@ -733,25 +752,27 @@ struct ArchiveTests {
#expect(try ctx.count(for: req) == 1)
}
- @Test("a complete archive replaces an earlier no-replay fallback")
- func materializeUpgradesReplayFallback() throws {
+ @Test("a complete Chronicle replaces an earlier waiting projection")
+ func materializeUpgradesWaitingReplay() throws {
let persistence = makeTestPersistence()
let ctx = persistence.viewContext
let original = UUID()
let snapshot = sampleSnapshot(originalGameID: original)
- let fallback = Archive.payload(from: snapshot, replayAvailable: false)
+ let fallback = Archive.payload(from: snapshot, replayState: .waiting(missing: 2))
let initial = try #require(Archive.materialize(fallback, in: ctx))
try ctx.save()
- #expect(initial.replayUnavailable)
+ #expect(!initial.replayUnavailable)
+ #expect(initial.replayMissingDeviceCount?.intValue == 2)
#expect(((initial.journal as? Set<JournalEntity>) ?? []).isEmpty)
- let complete = Archive.payload(from: snapshot, replayAvailable: true)
+ let complete = Archive.payload(from: snapshot)
let upgraded = try #require(Archive.materialize(complete, in: ctx))
try ctx.save()
#expect(upgraded === initial)
#expect(!upgraded.replayUnavailable)
+ #expect(upgraded.replayMissingDeviceCount == nil)
#expect(upgraded.replayCacheComplete)
#expect(((upgraded.journal as? Set<JournalEntity>) ?? []).count == 3)