commit 57bfdf38421d7fd20f202f1cb285c8f4b0625eac
parent 8ebbef3cf89ae36f754e5705de62f51eb16bd0ab
Author: Michael Camilleri <[email protected]>
Date: Sat, 30 May 2026 10:57:47 +0900
Upload move journal at game completion
This commit adds the Phase 2 upload pipeline on top of the local move
journal: when a game completes — a win or a resignation — the device
encodes its whole JournalEntity log and pushes it to CloudKit as a
Journal record carrying the moves as a CKAsset. Merging every device's
upload by timestamp is what a later replay viewer will read; this commit
only puts the data in place.
There is one Journal record per (game, author, device), named
journal-<gameID>-<authorID>-<deviceID> and written into the game's
existing zone, mirroring the Moves record. Like Ping and Decision it is
write-once with no system-fields archive; buildRecord reconstructs the
asset from the durable JournalEntity rows rather than an in-memory
stash, so a pending upload survives an app kill, and
MovesJournal.flush() is awaited before enqueue to beat the journal's
async persistence. Inbound Journal records are deliberately ignored —
the replay viewer will fetch them on demand — so collaborators don't
apply each other's logs.
JournalCodec is the wire format, carrying the mark as the single
lossless markCode (matching JournalEntity) rather than the Moves
two-bool flattening, with a forward-compatible decoder. The completion
lockout of undo and the replay UI are deferred.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
8 files changed, 554 insertions(+), 1 deletion(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -54,6 +54,7 @@
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; };
+ 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */; };
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; };
740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
@@ -192,6 +193,7 @@
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; };
+ 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; };
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; };
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
@@ -365,6 +367,7 @@
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
+ 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */,
78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */,
@@ -699,6 +702,7 @@
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
+ 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */,
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -232,6 +232,12 @@ final class GameStore {
/// changes and needs to be re-pushed.
private let onGameUpdated: (String) -> Void
+ /// Called once a game completes (win or resign) with `(gameID, authorID)`,
+ /// so this device's move journal can be uploaded for later replay (Phase
+ /// 2). Separate from `onGameUpdated`: that re-pushes the Game record, this
+ /// pushes the per-device Journal asset.
+ private let onJournalComplete: (UUID, String) -> Void
+
/// Fires when the count of shared games with unseen other-author moves
/// may have changed (inbound moves merged, a game opened, a game
/// deleted). Consumers refresh the app-icon badge from here.
@@ -249,6 +255,7 @@ final class GameStore {
onGameCreated: @escaping (String) -> Void,
onGameUpdated: @escaping (String) -> Void,
onGameDeleted: @escaping (GameCloudDeletion) -> Void,
+ onJournalComplete: @escaping (UUID, String) -> Void = { _, _ in },
eventLog: EventLog? = nil
) {
self.persistence = persistence
@@ -261,6 +268,7 @@ final class GameStore {
self.onGameCreated = onGameCreated
self.onGameUpdated = onGameUpdated
self.onGameDeleted = onGameDeleted
+ self.onJournalComplete = onJournalComplete
self.eventLog = eventLog
}
@@ -596,6 +604,7 @@ final class GameStore {
onGameUpdated(ckName)
}
Task { await movesUpdater.flush() }
+ triggerJournalUpload(id: id)
// Clean up current references
currentGame = nil
@@ -638,9 +647,22 @@ final class GameStore {
onGameUpdated(ckName)
}
Task { await movesUpdater.flush() }
+ triggerJournalUpload(id: id)
return true
}
+ /// Uploads this device's move journal for a just-completed game (Phase 2).
+ /// Flushes the journal's async persistence first so the record builder,
+ /// reading Core Data on its own context, sees every entry. Attributed to
+ /// the local user (not the solver) — a resigner still has a log to upload.
+ private func triggerJournalUpload(id: UUID) {
+ guard let authorID = authorIDProvider(), !authorID.isEmpty else { return }
+ Task {
+ await movesJournal.flush()
+ onJournalComplete(id, authorID)
+ }
+ }
+
// MARK: - Engagement room
/// The shared live-engagement room creds for `gameID` (an encoded
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -323,6 +323,16 @@ final class MovesJournal {
}
}
+ /// Awaits the background persistence queue so every recorded entry has
+ /// landed in the store. `record(...)` persists asynchronously and the
+ /// in-memory list is authoritative for play, but the Phase 2 upload
+ /// reconstructs the asset from Core Data on a *separate* background
+ /// context — call this first (e.g. at completion) so that context sees the
+ /// final entries rather than racing the in-flight saves.
+ func flush() async {
+ await backgroundContext.perform { }
+ }
+
private func persist(_ value: JournalValue, gameID: UUID) {
let ctx = backgroundContext
ctx.perform {
@@ -355,7 +365,10 @@ final class MovesJournal {
}
}
- private nonisolated static func value(from entity: JournalEntity) -> JournalValue {
+ /// 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.
+ nonisolated static func value(from entity: JournalEntity) -> JournalValue {
JournalValue(
seq: entity.seq,
timestamp: entity.timestamp ?? .distantPast,
@@ -373,3 +386,120 @@ final class MovesJournal {
)
}
}
+
+/// Wire format for a device's journal, encoded once at game completion and
+/// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful
+/// dump of `[JournalValue]` in `seq` order — merging every device's decoded
+/// dump by `timestamp` reconstructs the whole game for replay. The mark is
+/// carried as the single lossless `markCode` (`CellMarkCodec.code`), matching
+/// `JournalEntity`, not the `markKind` + two-bool flattening the synced `Moves`
+/// format uses.
+enum JournalCodec {
+ struct Payload: Codable, Equatable {
+ struct Entry: Codable, Equatable {
+ let seq: Int64
+ let timestamp: Date
+ let row: Int
+ let col: Int
+ let letter: String
+ let markCode: Int16
+ let cellAuthorID: String?
+ let actingAuthorID: String?
+ let kind: Int16
+ let targetSeq: Int64?
+ let batchID: UUID?
+ let prevSeqAtCell: Int64?
+
+ init(
+ seq: Int64,
+ timestamp: Date,
+ row: Int,
+ col: Int,
+ letter: String,
+ markCode: Int16,
+ cellAuthorID: String?,
+ actingAuthorID: String?,
+ kind: Int16,
+ targetSeq: Int64?,
+ batchID: UUID?,
+ prevSeqAtCell: Int64?
+ ) {
+ self.seq = seq
+ self.timestamp = timestamp
+ self.row = row
+ self.col = col
+ self.letter = letter
+ self.markCode = markCode
+ self.cellAuthorID = cellAuthorID
+ self.actingAuthorID = actingAuthorID
+ self.kind = kind
+ self.targetSeq = targetSeq
+ self.batchID = batchID
+ self.prevSeqAtCell = prevSeqAtCell
+ }
+
+ // Optionals are decoded leniently so a record written by a newer
+ // client that added fields still decodes cleanly on an older one
+ // (same forward-compat stance as `MovesCodec.Payload.Entry`).
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ seq = try c.decode(Int64.self, forKey: .seq)
+ timestamp = try c.decode(Date.self, forKey: .timestamp)
+ row = try c.decode(Int.self, forKey: .row)
+ col = try c.decode(Int.self, forKey: .col)
+ letter = try c.decode(String.self, forKey: .letter)
+ markCode = try c.decode(Int16.self, forKey: .markCode)
+ kind = try c.decode(Int16.self, forKey: .kind)
+ cellAuthorID = try? c.decode(String.self, forKey: .cellAuthorID)
+ actingAuthorID = try? c.decode(String.self, forKey: .actingAuthorID)
+ targetSeq = try? c.decode(Int64.self, forKey: .targetSeq)
+ batchID = try? c.decode(UUID.self, forKey: .batchID)
+ prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell)
+ }
+ }
+ let entries: [Entry]
+ }
+
+ static func encode(_ values: [JournalValue]) throws -> Data {
+ let entries = values
+ .sorted { $0.seq < $1.seq }
+ .map { value in
+ Payload.Entry(
+ seq: value.seq,
+ timestamp: value.timestamp,
+ row: value.position.row,
+ col: value.position.col,
+ letter: value.state.letter,
+ markCode: CellMarkCodec.code(value.state.mark),
+ cellAuthorID: value.state.cellAuthorID,
+ actingAuthorID: value.actingAuthorID,
+ kind: value.kind.rawValue,
+ targetSeq: value.targetSeq,
+ batchID: value.batchID,
+ prevSeqAtCell: value.prevSeqAtCell
+ )
+ }
+ return try JSONEncoder().encode(Payload(entries: entries))
+ }
+
+ static func decode(_ data: Data) throws -> [JournalValue] {
+ let payload = try JSONDecoder().decode(Payload.self, from: data)
+ return payload.entries.map { entry in
+ JournalValue(
+ seq: entry.seq,
+ timestamp: entry.timestamp,
+ position: GridPosition(row: entry.row, col: entry.col),
+ state: JournalCellState(
+ letter: entry.letter,
+ mark: CellMarkCodec.mark(code: entry.markCode),
+ cellAuthorID: entry.cellAuthorID
+ ),
+ actingAuthorID: entry.actingAuthorID,
+ kind: JournalKind(rawValue: entry.kind) ?? .input,
+ targetSeq: entry.targetSeq,
+ batchID: entry.batchID,
+ prevSeqAtCell: entry.prevSeqAtCell
+ )
+ }
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -238,6 +238,12 @@ final class AppServices {
guard preferences.isICloudSyncEnabled else { return }
onGameDeletedHandler(deletion)
},
+ onJournalComplete: { [preferences, syncEngine] gameID, authorID in
+ Task {
+ guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
+ await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
+ }
+ },
eventLog: eventLog
)
self.store = store
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -69,6 +69,28 @@ extension SyncEngine {
zone: zoneID,
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.
+ 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.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:))
+ let updatedAt = entries.map(\.timestamp).max() ?? Date()
+ return try? RecordSerializer.journalRecord(
+ gameID: gameID,
+ authorID: authorID,
+ deviceID: deviceID,
+ updatedAt: updatedAt,
+ entries: entries,
+ zone: zoneID
+ )
} else if name.hasPrefix("player-") {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
req.predicate = NSPredicate(format: "ckRecordName == %@", name)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -43,6 +43,18 @@ enum RecordSerializer {
"moves-\(gameID.uuidString)-\(authorID)-\(deviceID)"
}
+ /// One Journal record per `(game, authorID, deviceID)` — this device's
+ /// whole local move log, uploaded once at completion (Phase 2). Same
+ /// `(game, author, device)` shape as the Moves record so collaborators'
+ /// uploads stay distinct and mergeable by timestamp for replay.
+ static func recordName(
+ forJournalInGame gameID: UUID,
+ authorID: String,
+ deviceID: String
+ ) -> String {
+ "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)"
+ }
+
/// One player record per (game, author). Each participant only ever
/// writes to their own slot, so there are no write-write conflicts on
/// the field.
@@ -134,6 +146,40 @@ enum RecordSerializer {
return record
}
+ // MARK: - Journal record building
+
+ /// Builds the `Journal` record carrying this device's full move log as a
+ /// `CKAsset`. Write-once at completion, so there is no system-fields
+ /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a
+ /// re-send of an already-uploaded journal is a benign conflict the send
+ /// path drops. The encoded entries are written to a temp file the same way
+ /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset
+ /// on upload and the OS reaps the temporary directory.
+ static func journalRecord(
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ updatedAt: Date,
+ entries: [JournalValue],
+ zone: CKRecordZone.ID
+ ) throws -> CKRecord {
+ let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID)
+ let recordID = CKRecord.ID(recordName: name, zoneID: zone)
+ let record = CKRecord(recordType: "Journal", recordID: recordID)
+ record["authorID"] = authorID as CKRecordValue
+ record["deviceID"] = deviceID as CKRecordValue
+ record["updatedAt"] = updatedAt as CKRecordValue
+
+ let data = try JournalCodec.encode(entries)
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("json")
+ try data.write(to: url, options: .atomic)
+ record["entries"] = CKAsset(fileURL: url)
+
+ return record
+ }
+
static func gameRecord(
from entity: GameEntity,
recordID: CKRecord.ID,
@@ -403,6 +449,28 @@ enum RecordSerializer {
return (gameID, authorID, deviceID)
}
+ /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts.
+ /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix
+ /// after the final `-`; authorID may itself contain dashes).
+ static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? {
+ let prefix = "journal-"
+ guard name.hasPrefix(prefix) else { return nil }
+ let rest = name.dropFirst(prefix.count)
+ let uuidLength = 36
+ guard rest.count > uuidLength,
+ rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
+ else { return nil }
+ let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
+ guard let gameID = UUID(uuidString: uuidPart) else { return nil }
+ let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
+ let tail = rest[afterUUID...]
+ guard let lastDash = tail.lastIndex(of: "-") else { return nil }
+ let authorID = String(tail[tail.startIndex..<lastDash])
+ let deviceID = String(tail[tail.index(after: lastDash)...])
+ guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
+ return (gameID, authorID, deviceID)
+ }
+
// MARK: - Applying incoming CKRecords to Core Data
/// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -903,6 +903,29 @@ actor SyncEngine {
sendChangesDetached(on: engine)
}
+ /// Registers this device's move journal as a pending send, into the game's
+ /// existing zone (no `saveZone` — by completion the zone is long since
+ /// created). One `Journal` record per (game, author, device); the asset is
+ /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so
+ /// no payload is stashed here. Called once at completion; a re-send is a
+ /// benign conflict the send path drops. Skipped on an access-revoked game,
+ /// where any save would just fail with `.zoneNotFound`.
+ func enqueueJournalUpload(gameID: UUID, authorID: String) {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx),
+ !info.isAccessRevoked else { return }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
+ guard let engine else { return }
+ let recordName = RecordSerializer.recordName(
+ forJournalInGame: gameID,
+ authorID: authorID,
+ deviceID: RecordSerializer.localDeviceID
+ )
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ sendChangesDetached(on: engine)
+ }
+
// MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
func fetchChanges(source: String = "manual") async throws {
@@ -1224,6 +1247,12 @@ actor SyncEngine {
let gid = UUID(uuidString: dKey) {
effects.removed.insert(gid)
}
+ case "Journal":
+ // Phase 2 upload-only: a collaborator's journal asset is
+ // not applied to Core Data here. The replay viewer (Phase
+ // 2b) fetches journals on demand; this case marks that
+ // omission as deliberate rather than a missing apply path.
+ break
default:
break
}
@@ -1313,6 +1342,7 @@ actor SyncEngine {
private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String {
let name = recordID.recordName
if name.hasPrefix("moves-") { return "Moves" }
+ if name.hasPrefix("journal-") { return "Journal" }
if name.hasPrefix("player-") { return "Player" }
if name.hasPrefix("game-") { return "Game" }
if name.hasPrefix("ping-") { return "Ping" }
diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift
@@ -0,0 +1,271 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Phase 2 upload pipeline: the `JournalCodec` wire format, the `Journal`
+/// record naming/building in `RecordSerializer`, and the end-to-end enqueue +
+/// `buildRecord` path through `SyncEngine`. The replay viewer (Phase 2b) is not
+/// covered here.
+
+// MARK: - Wire format
+
+@Suite("JournalCodec")
+struct JournalCodecTests {
+
+ private func value(
+ seq: Int64,
+ row: Int,
+ col: Int,
+ letter: String,
+ mark: CellMark,
+ kind: JournalKind,
+ actingAuthorID: String? = nil,
+ cellAuthorID: String? = nil,
+ targetSeq: Int64? = nil,
+ batchID: UUID? = nil,
+ prevSeqAtCell: Int64? = nil
+ ) -> JournalValue {
+ JournalValue(
+ seq: seq,
+ timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)),
+ position: GridPosition(row: row, col: col),
+ state: JournalCellState(letter: letter, mark: mark, cellAuthorID: cellAuthorID),
+ actingAuthorID: actingAuthorID,
+ kind: kind,
+ targetSeq: targetSeq,
+ batchID: batchID,
+ prevSeqAtCell: prevSeqAtCell
+ )
+ }
+
+ @Test("encode/decode round-trips every field, including nil optionals")
+ func roundTrip() throws {
+ let batch = UUID()
+ let values = [
+ value(seq: 0, row: 0, col: 0, letter: "A", mark: .pen(checked: nil), kind: .input,
+ actingAuthorID: "alice", cellAuthorID: "alice"),
+ value(seq: 1, row: 1, col: 2, letter: "B", mark: .pencil(checked: .wrong), kind: .clear,
+ actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0),
+ value(seq: 2, row: 2, col: 2, letter: "", mark: .none, kind: .undo,
+ targetSeq: 0, batchID: batch),
+ value(seq: 3, row: 0, col: 1, letter: "C", mark: .revealed, kind: .reveal),
+ ]
+ let data = try JournalCodec.encode(values)
+ let decoded = try JournalCodec.decode(data)
+ #expect(decoded == values)
+ }
+
+ @Test("encode sorts entries by seq")
+ func encodeSortsBySeq() throws {
+ let values = [
+ value(seq: 2, row: 0, col: 0, letter: "C", mark: .none, kind: .input),
+ value(seq: 0, row: 0, col: 0, letter: "A", mark: .none, kind: .input),
+ value(seq: 1, row: 0, col: 0, letter: "B", mark: .none, kind: .input),
+ ]
+ let decoded = try JournalCodec.decode(try JournalCodec.encode(values))
+ #expect(decoded.map(\.seq) == [0, 1, 2])
+ }
+
+ @Test("decode tolerates a payload missing the optional keys (forward-compat)")
+ func decodeToleratesMissingOptionals() throws {
+ let ts = Date(timeIntervalSince1970: 1_700_000_000)
+ let json: [String: Any] = ["entries": [[
+ "seq": 5,
+ "timestamp": ts.timeIntervalSinceReferenceDate,
+ "row": 1,
+ "col": 2,
+ "letter": "Z",
+ "markCode": 7,
+ "kind": 2,
+ ]]]
+ let data = try JSONSerialization.data(withJSONObject: json)
+ let decoded = try JournalCodec.decode(data)
+ let entry = try #require(decoded.first)
+ #expect(entry.seq == 5)
+ #expect(entry.position == GridPosition(row: 1, col: 2))
+ #expect(entry.state.letter == "Z")
+ #expect(entry.state.mark == .revealed)
+ #expect(entry.kind == .reveal)
+ #expect(entry.state.cellAuthorID == nil)
+ #expect(entry.actingAuthorID == nil)
+ #expect(entry.targetSeq == nil)
+ #expect(entry.batchID == nil)
+ #expect(entry.prevSeqAtCell == nil)
+ }
+}
+
+// MARK: - Record naming + building
+
+@Suite("RecordSerializer Journal")
+struct RecordSerializerJournalTests {
+
+ private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
+ private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) }
+
+ @Test("Journal record name uses the expected format")
+ func nameFormat() {
+ let name = RecordSerializer.recordName(
+ forJournalInGame: gameID,
+ authorID: "alice",
+ deviceID: "deadbeef"
+ )
+ #expect(name == "journal-\(gameID.uuidString)-alice-deadbeef")
+ }
+
+ @Test("Journal record name parses back, even when authorID contains dashes")
+ func nameRoundTrip() {
+ let name = RecordSerializer.recordName(
+ forJournalInGame: gameID,
+ authorID: "alice-with-dashes",
+ deviceID: "deadbeef"
+ )
+ let parsed = RecordSerializer.parseJournalRecordName(name)
+ #expect(parsed?.0 == gameID)
+ #expect(parsed?.1 == "alice-with-dashes")
+ #expect(parsed?.2 == "deadbeef")
+ }
+
+ @Test("parse rejects a non-journal name")
+ func parseRejectsOtherPrefix() {
+ let moves = RecordSerializer.recordName(
+ forMovesInGame: gameID, authorID: "alice", deviceID: "deadbeef"
+ )
+ #expect(RecordSerializer.parseJournalRecordName(moves) == nil)
+ }
+
+ @Test("Journal record carries the entries asset, which decodes back")
+ func recordAssetRoundTrips() throws {
+ let entries = [
+ JournalValue(
+ seq: 0,
+ timestamp: Date(timeIntervalSince1970: 1_700_000_000),
+ position: GridPosition(row: 0, col: 0),
+ state: JournalCellState(letter: "A", mark: .pen(checked: nil), cellAuthorID: "alice"),
+ actingAuthorID: "alice",
+ kind: .input,
+ targetSeq: nil,
+ batchID: nil,
+ prevSeqAtCell: nil
+ ),
+ JournalValue(
+ seq: 1,
+ timestamp: Date(timeIntervalSince1970: 1_700_000_010),
+ position: GridPosition(row: 1, col: 2),
+ state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil),
+ actingAuthorID: "bob",
+ kind: .reveal,
+ targetSeq: nil,
+ batchID: UUID(),
+ prevSeqAtCell: nil
+ ),
+ ]
+ let updatedAt = Date(timeIntervalSince1970: 1_700_000_010)
+ let record = try RecordSerializer.journalRecord(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deadbeef",
+ updatedAt: updatedAt,
+ entries: entries,
+ zone: zoneID
+ )
+
+ #expect(record.recordType == "Journal")
+ #expect(record["authorID"] as? String == "alice")
+ #expect(record["deviceID"] as? String == "deadbeef")
+ #expect(record["updatedAt"] as? Date == updatedAt)
+
+ let asset = try #require(record["entries"] as? CKAsset)
+ let url = try #require(asset.fileURL)
+ let data = try Data(contentsOf: url)
+ #expect(try JournalCodec.decode(data) == entries)
+ }
+}
+
+// MARK: - End-to-end enqueue + build
+
+@Suite("Journal upload via SyncEngine", .serialized)
+@MainActor
+struct JournalUploadEngineTests {
+
+ private func makeEngine(persistence: PersistenceController) async -> SyncEngine {
+ let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
+ let engine = SyncEngine(container: container, persistence: persistence)
+ await engine.start()
+ return engine
+ }
+
+ private func makePrivateGame(in ctx: NSManagedObjectContext) throws -> UUID {
+ let id = UUID()
+ let zoneName = "game-\(id.uuidString)"
+ let entity = GameEntity(context: ctx)
+ entity.id = id
+ entity.title = "Private"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = zoneName
+ entity.ckZoneName = zoneName
+ entity.databaseScope = 0
+ try ctx.save()
+ return id
+ }
+
+ private func seedJournalRow(gameID: UUID, seq: Int64, in ctx: NSManagedObjectContext) throws {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ let game = try #require(try ctx.fetch(gameReq).first)
+ let row = JournalEntity(context: ctx)
+ row.game = game
+ row.gameID = gameID
+ row.seq = seq
+ row.timestamp = Date(timeIntervalSince1970: 1_700_000_000 + Double(seq))
+ row.row = 0
+ row.col = Int16(seq)
+ row.letter = "A"
+ row.markCode = 0
+ row.kind = JournalKind.input.rawValue
+ try ctx.save()
+ }
+
+ @Test("enqueue registers a buildable Journal save that survives a batch build")
+ func enqueuePreservesBuildableJournal() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = try makePrivateGame(in: ctx)
+ try seedJournalRow(gameID: gameID, seq: 0, in: ctx)
+ try seedJournalRow(gameID: gameID, seq: 1, in: ctx)
+ let engine = await makeEngine(persistence: persistence)
+
+ await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice")
+ let before = await engine.pendingSaveRecordNames(scope: .private)
+ let journalName = try #require(before.first { $0.hasPrefix("journal-") })
+
+ // The JournalEntity rows back the record, so `buildRecord` materializes
+ // it and the reap must not fire.
+ _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
+
+ let after = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(after.contains(journalName))
+ }
+
+ @Test("an enqueued journal with no rows is reaped (nothing to upload)")
+ func emptyJournalIsReaped() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = try makePrivateGame(in: ctx)
+ let engine = await makeEngine(persistence: persistence)
+
+ await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice")
+ let before = await engine.pendingSaveRecordNames(scope: .private)
+ let journalName = try #require(before.first { $0.hasPrefix("journal-") })
+
+ _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
+
+ let after = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(!after.contains(journalName))
+ }
+}