crossmate

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

Journal.swift (28604B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// Why a journal entry exists. Every grid-changing move is recorded so the
      5 /// whole game can be replayed (Phase 2). Letter `input` (adds/deletes) and
      6 /// `clear` (the bulk clear gesture) are undoable; `check`/`reveal` are recorded
      7 /// for replay but the undo/redo machine never offers them as steps — checking
      8 /// and revealing are "help" actions, not edits you rewind. `undo`/`redo` rows
      9 /// are the forward mutations produced by reversing or re-applying an undoable
     10 /// step; they are real grid changes (so they sync) but the stack machine treats
     11 /// them as stack operations.
     12 enum JournalKind: Int16, Sendable {
     13     case input = 0
     14     case check = 1
     15     case reveal = 2
     16     case clear = 3
     17     case undo = 4
     18     case redo = 5
     19 
     20     /// Whether this kind is an undoable step (as opposed to a recorded-only
     21     /// help gesture or a stack operation).
     22     var isUndoable: Bool { self == .input || self == .clear }
     23 }
     24 
     25 /// The after-state of a single cell touch — what the cell became, not what it
     26 /// was. The "before" value is never stored; it is recovered by following
     27 /// `JournalValue.prevSeqAtCell`.
     28 struct JournalCellState: Equatable, Sendable {
     29     var letter: String
     30     var mark: CellMark
     31     var cellAuthorID: String?
     32 
     33     static let empty = JournalCellState(letter: "", mark: .none, cellAuthorID: nil)
     34 
     35     /// Letter match for the supersession guard: undo only fires when the cell
     36     /// still holds the letter the move produced. Only the letter is compared —
     37     /// the journal logs letter adds/deletes, so a peer's check/reveal (a mark
     38     /// change) shouldn't block undoing one's own letter, while a peer changing
     39     /// the letter itself should.
     40     func letterMatches(_ other: JournalCellState) -> Bool {
     41         letter == other.letter
     42     }
     43 }
     44 
     45 /// One recorded cell touch. Append-only and immutable once written.
     46 /// `prevSeqAtCell` points at the previous entry for the same `(row, col)`
     47 /// (any kind), or `nil` when the cell was empty before — that pointer is how a
     48 /// before-state is recovered in O(1) without scanning the log.
     49 struct JournalValue: Equatable, Sendable {
     50     let seq: Int64
     51     let timestamp: Date
     52     let position: GridPosition
     53     /// The cell state observed immediately before this local touch. New local
     54     /// rows carry this so undo can restore a collaborator's pre-existing
     55     /// letter even though that letter is not in this device's local journal.
     56     /// Older rows and replay-upload rows may be nil and fall back to
     57     /// `prevSeqAtCell`.
     58     let beforeState: JournalCellState?
     59     let state: JournalCellState
     60     let actingAuthorID: String?
     61     let kind: JournalKind
     62     let targetSeq: Int64?
     63     let batchID: UUID?
     64     let prevSeqAtCell: Int64?
     65     /// The cursor direction at the moment of input, so undo/redo can land the
     66     /// cursor pointing the way the letter was originally typed. Only meaningful
     67     /// for `.input` entries; `nil` for clears, help gestures, and undo/redo
     68     /// rows (whose cursor direction comes from the input op they reverse).
     69     let direction: Puzzle.Direction?
     70 
     71     init(
     72         seq: Int64,
     73         timestamp: Date,
     74         position: GridPosition,
     75         beforeState: JournalCellState? = nil,
     76         state: JournalCellState,
     77         actingAuthorID: String?,
     78         kind: JournalKind,
     79         targetSeq: Int64?,
     80         batchID: UUID?,
     81         prevSeqAtCell: Int64?,
     82         direction: Puzzle.Direction? = nil
     83     ) {
     84         self.seq = seq
     85         self.timestamp = timestamp
     86         self.position = position
     87         self.beforeState = beforeState
     88         self.state = state
     89         self.actingAuthorID = actingAuthorID
     90         self.kind = kind
     91         self.targetSeq = targetSeq
     92         self.batchID = batchID
     93         self.prevSeqAtCell = prevSeqAtCell
     94         self.direction = direction
     95     }
     96 }
     97 
     98 /// One cell to rewrite as part of an undo or redo, with the guard value the
     99 /// caller checks against the live grid before applying.
    100 struct JournalRestore: Equatable, Sendable {
    101     let position: GridPosition
    102     /// The value to write into the cell.
    103     let restoreTo: JournalCellState
    104     /// Skip this cell if the live grid no longer shows this — it means a
    105     /// collaborator (or a later edit) changed it since.
    106     let expectedCurrent: JournalCellState
    107     /// The undoable entry being reversed / re-applied.
    108     let targetSeq: Int64
    109 }
    110 
    111 /// The cells to restore for one undo/redo step, plus the step's identity so the
    112 /// caller can mark it consumed if every cell turned out to be superseded.
    113 struct JournalPlan: Equatable, Sendable {
    114     let restores: [JournalRestore]
    115     let stepID: Int64
    116     /// The kind of the undoable step being reversed/re-applied (`.input` or
    117     /// `.clear`). Lets the caller decide where to put the cursor — onto a
    118     /// single-cell input, but not after a bulk clear.
    119     let kind: JournalKind
    120     /// The direction the reversed/re-applied input step was typed in, so the
    121     /// caller can orient the cursor to match. `nil` for `.clear` (no single
    122     /// direction) and for older entries recorded before direction was tracked.
    123     let direction: Puzzle.Direction?
    124 }
    125 
    126 /// Local, append-only log of every grid move, and the undo/redo derivation
    127 /// built on top of it. Local only — never synced in Phase 1. The current
    128 /// game's entries are held in memory (loaded lazily, so undo survives a
    129 /// relaunch) and each new entry is persisted on a background context; the
    130 /// in-memory list is authoritative for the session.
    131 @MainActor
    132 final class MovesJournal {
    133     private let persistence: PersistenceController
    134     private let backgroundContext: NSManagedObjectContext
    135 
    136     private var loadedGameID: UUID?
    137     private var entries: [JournalValue] = []
    138     private var bySeq: [Int64: JournalValue] = [:]
    139     private var lastSeqAtCell: [GridPosition: Int64] = [:]
    140     private var nextSeq: Int64 = 0
    141     private var loadingGameID: UUID?
    142     private var loadGeneration = 0
    143 
    144     /// Steps whose every cell was found superseded when the caller tried to
    145     /// apply them, so they should be passed over. Transient (session-only): a
    146     /// fully-superseded step has no grid effect to record, so without this the
    147     /// stack would keep re-offering it and undo would be stuck. Cleared on load.
    148     private var consumedUndoSteps: Set<Int64> = []
    149     private var consumedRedoSteps: Set<Int64> = []
    150 
    151     init(persistence: PersistenceController) {
    152         self.persistence = persistence
    153         self.backgroundContext = persistence.container.newBackgroundContext()
    154         // Each appended entry links its `GameEntity` (for cascade-delete),
    155         // which touches that row's inverse relationship. Meanwhile the grid
    156         // writer and summary backfills bump the same `GameEntity` constantly,
    157         // so this context's snapshot of it goes stale between saves. With the
    158         // default `NSErrorMergePolicy` that surfaces as a 133020 merge conflict
    159         // and the *entire* save — including the new journal row — is rolled
    160         // back, silently dropping the entry from disk while it lingers in the
    161         // in-memory list. Undo then works for the session but the row is gone
    162         // on relaunch, leaving the journal behind the grid. Store-trump keeps
    163         // the store's authoritative `GameEntity` and applies only our
    164         // relationship insert, so the journal write always lands.
    165         self.backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump
    166     }
    167 
    168     // MARK: - Recording
    169 
    170     /// Appends a cell touch and returns the recorded value. Assigns the next
    171     /// `seq`, links `prevSeqAtCell`, and persists asynchronously.
    172     @discardableResult
    173     func record(
    174         gameID: UUID,
    175         position: GridPosition,
    176         beforeState: JournalCellState? = nil,
    177         state: JournalCellState,
    178         actingAuthorID: String?,
    179         kind: JournalKind,
    180         targetSeq: Int64?,
    181         batchID: UUID?,
    182         direction: Puzzle.Direction? = nil
    183     ) -> JournalValue {
    184         ensureLoaded(gameID)
    185         let value = JournalValue(
    186             seq: nextSeq,
    187             timestamp: Date(),
    188             position: position,
    189             beforeState: beforeState,
    190             state: state,
    191             actingAuthorID: actingAuthorID,
    192             kind: kind,
    193             targetSeq: targetSeq,
    194             batchID: batchID,
    195             prevSeqAtCell: lastSeqAtCell[position],
    196             direction: direction
    197         )
    198         nextSeq += 1
    199         entries.append(value)
    200         bySeq[value.seq] = value
    201         lastSeqAtCell[position] = value.seq
    202         persist(value, gameID: gameID)
    203         return value
    204     }
    205 
    206     // MARK: - Replay
    207 
    208     /// Read-only snapshot of the recorded log for a game, in seq order —
    209     /// every move including checks and reveals. The basis for Phase 2 replay.
    210     func recordedEntries(gameID: UUID) -> [JournalValue] {
    211         ensureLoaded(gameID)
    212         return entries
    213     }
    214 
    215     // MARK: - Undo / redo queries
    216 
    217     func canUndo(gameID: UUID) -> Bool {
    218         guard loadedGameID == gameID else { return false }
    219         return stacks().live.contains { !consumedUndoSteps.contains(stepID($0)) }
    220     }
    221 
    222     func canRedo(gameID: UUID) -> Bool {
    223         guard loadedGameID == gameID else { return false }
    224         return stacks().redo.contains { !consumedRedoSteps.contains(stepID($0)) }
    225     }
    226 
    227     /// The cells to restore to undo the most recent still-undoable step, or
    228     /// `nil` when there is nothing to undo. Does not mutate journal state — the
    229     /// `undo` rows the caller records via `record` drive the stack forward.
    230     func planUndo(gameID: UUID) -> JournalPlan? {
    231         ensureLoaded(gameID)
    232         guard let op = stacks().live.last(where: { !consumedUndoSteps.contains(stepID($0)) })
    233         else { return nil }
    234         let restores = op.entries.map { entry in
    235             JournalRestore(
    236                 position: entry.position,
    237                 restoreTo: observedBeforeState(of: entry),
    238                 expectedCurrent: entry.state,
    239                 targetSeq: entry.seq
    240             )
    241         }
    242         return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction)
    243     }
    244 
    245     /// The cells to restore to redo the most recently undone step.
    246     func planRedo(gameID: UUID) -> JournalPlan? {
    247         ensureLoaded(gameID)
    248         guard let op = stacks().redo.last(where: { !consumedRedoSteps.contains(stepID($0)) })
    249         else { return nil }
    250         let restores = op.entries.map { entry in
    251             JournalRestore(
    252                 position: entry.position,
    253                 restoreTo: entry.state,
    254                 expectedCurrent: observedBeforeState(of: entry),
    255                 targetSeq: entry.seq
    256             )
    257         }
    258         return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction)
    259     }
    260 
    261     /// Records that a planned undo/redo step had no surviving cells (all
    262     /// superseded), so the next plan skips past it instead of re-offering it.
    263     func markUndoConsumed(stepID: Int64, gameID: UUID) {
    264         ensureLoaded(gameID)
    265         consumedUndoSteps.insert(stepID)
    266     }
    267 
    268     func markRedoConsumed(stepID: Int64, gameID: UUID) {
    269         ensureLoaded(gameID)
    270         consumedRedoSteps.insert(stepID)
    271     }
    272 
    273     // MARK: - Derivation
    274 
    275     private struct Operation {
    276         let kind: JournalKind
    277         let entries: [JournalValue]
    278     }
    279 
    280     /// Stable identity for an operation — the seq of its first entry, which is
    281     /// unique and immutable across re-derivations of the same log.
    282     private func stepID(_ op: Operation) -> Int64 {
    283         op.entries.first?.seq ?? -1
    284     }
    285 
    286     /// The before-state of an entry: the after-state of the previous touch at
    287     /// the same cell, or empty if there was none.
    288     private func beforeState(of entry: JournalValue) -> JournalCellState {
    289         guard let prev = entry.prevSeqAtCell, let value = bySeq[prev] else {
    290             return .empty
    291         }
    292         return value.state
    293     }
    294 
    295     private func observedBeforeState(of entry: JournalValue) -> JournalCellState {
    296         entry.beforeState ?? beforeState(of: entry)
    297     }
    298 
    299     /// Groups the log into operations (a bulk gesture or one undo/redo op is a
    300     /// single operation; each single-cell edit is its own), then runs the stack
    301     /// machine. `live` holds applied undoable operations newest-last; `redo`
    302     /// holds undone ones available to re-apply. `check`/`reveal` ops are
    303     /// recorded but inert here.
    304     ///
    305     /// `undo`/`redo` ops are matched to the undoable op they act on by
    306     /// `targetSeq`, not by stack position: the supersession guard can skip a
    307     /// superseded top step and undo an earlier one, so the op being reversed is
    308     /// not necessarily on top.
    309     private func stacks() -> (live: [Operation], redo: [Operation]) {
    310         var operations: [Operation] = []
    311         var i = 0
    312         while i < entries.count {
    313             let head = entries[i]
    314             var run = [head]
    315             var j = i + 1
    316             if let batch = head.batchID {
    317                 while j < entries.count,
    318                       entries[j].batchID == batch,
    319                       entries[j].kind == head.kind {
    320                     run.append(entries[j])
    321                     j += 1
    322                 }
    323             }
    324             operations.append(Operation(kind: head.kind, entries: run))
    325             i = j
    326         }
    327 
    328         // Each entry's seq maps to the op that owns it, so an undo/redo op can
    329         // find the exact undoable op its `targetSeq` refers to.
    330         var opIndexBySeq: [Int64: Int] = [:]
    331         for (index, op) in operations.enumerated() {
    332             for entry in op.entries { opIndexBySeq[entry.seq] = index }
    333         }
    334 
    335         var live: [Int] = []
    336         var redo: [Int] = []
    337         for (index, op) in operations.enumerated() {
    338             switch op.kind {
    339             case .undo:
    340                 guard let target = op.entries.first?.targetSeq,
    341                       let targetIndex = opIndexBySeq[target],
    342                       let pos = live.firstIndex(of: targetIndex) else { continue }
    343                 live.remove(at: pos)
    344                 redo.append(targetIndex)
    345             case .redo:
    346                 guard let target = op.entries.first?.targetSeq,
    347                       let targetIndex = opIndexBySeq[target],
    348                       let pos = redo.firstIndex(of: targetIndex) else { continue }
    349                 redo.remove(at: pos)
    350                 live.append(targetIndex)
    351             case .input, .clear:
    352                 redo.removeAll()       // a new undoable edit cuts the redo branch
    353                 live.append(index)
    354             case .check, .reveal:
    355                 break                  // recorded for replay, inert to undo/redo
    356             }
    357         }
    358         return (live.map { operations[$0] }, redo.map { operations[$0] })
    359     }
    360 
    361     // MARK: - Loading & persistence
    362 
    363     func preload(gameID: UUID) async {
    364         guard loadedGameID != gameID else { return }
    365         loadGeneration += 1
    366         let generation = loadGeneration
    367         loadingGameID = gameID
    368         let loaded = await loadValues(gameID)
    369         guard generation == loadGeneration else { return }
    370         applyLoaded(loaded, for: gameID)
    371         loadingGameID = nil
    372     }
    373 
    374     private func ensureLoaded(_ gameID: UUID) {
    375         guard loadedGameID != gameID else { return }
    376         loadGeneration += 1
    377         loadingGameID = nil
    378         applyLoaded(loadValuesSynchronously(gameID), for: gameID)
    379     }
    380 
    381     private func applyLoaded(_ loaded: [JournalValue], for gameID: UUID) {
    382         entries.removeAll()
    383         bySeq.removeAll()
    384         lastSeqAtCell.removeAll()
    385         consumedUndoSteps.removeAll()
    386         consumedRedoSteps.removeAll()
    387         nextSeq = 0
    388         loadedGameID = gameID
    389         for value in loaded {
    390             entries.append(value)
    391             bySeq[value.seq] = value
    392             lastSeqAtCell[value.position] = value.seq
    393             nextSeq = max(nextSeq, value.seq + 1)
    394         }
    395     }
    396 
    397     private func loadValues(_ gameID: UUID) async -> [JournalValue] {
    398         let ctx = backgroundContext
    399         return await ctx.perform {
    400             Self.fetchValues(for: gameID, in: ctx)
    401         }
    402     }
    403 
    404     private func loadValuesSynchronously(_ gameID: UUID) -> [JournalValue] {
    405         let ctx = backgroundContext
    406         return ctx.performAndWait {
    407             Self.fetchValues(for: gameID, in: ctx)
    408         }
    409     }
    410 
    411     private nonisolated static func fetchValues(for gameID: UUID, in ctx: NSManagedObjectContext) -> [JournalValue] {
    412         let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
    413         // `sourceDeviceID == nil` keeps this to *this device's* log: rows cached
    414         // from other devices for replay carry a source key and must not enter
    415         // the undo/redo model.
    416         req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg)
    417         req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
    418         let rows = (try? ctx.fetch(req)) ?? []
    419         return rows.map(Self.value(from:))
    420     }
    421 
    422     /// Awaits the background persistence queue so every recorded entry has
    423     /// landed in the store. `record(...)` persists asynchronously and the
    424     /// in-memory list is authoritative for play, but the Phase 2 upload
    425     /// reconstructs the asset from Core Data on a *separate* background
    426     /// context — call this first (e.g. at completion) so that context sees the
    427     /// final entries rather than racing the in-flight saves.
    428     func flush() async {
    429         await backgroundContext.perform { }
    430     }
    431 
    432     private func persist(_ value: JournalValue, gameID: UUID) {
    433         let ctx = backgroundContext
    434         let eventLog = persistence.eventLog
    435         ctx.perform {
    436             let entity = JournalEntity(context: ctx)
    437             // Local log: leave the source key nil (this device's own row).
    438             Self.assign(value, to: entity, gameID: gameID)
    439 
    440             let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    441             gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    442             gameReq.fetchLimit = 1
    443             entity.game = try? ctx.fetch(gameReq).first
    444 
    445             do {
    446                 try ctx.save()
    447             } catch {
    448                 let message = "MovesJournal: failed to persist entry: \(error)"
    449                 Task { @MainActor in
    450                     eventLog?.note(message, level: "error")
    451                 }
    452                 ctx.rollback()
    453             }
    454         }
    455     }
    456 
    457     /// Writes a `JournalValue`'s fields onto a `JournalEntity`, leaving the
    458     /// `game` relationship and the `sourceAuthorID`/`sourceDeviceID` key to the
    459     /// caller. Shared by the local-log `persist` and the replay cache (which
    460     /// also stamps the source device), so the value→row mapping lives in one
    461     /// place. `nonisolated` so the cache can call it on its own context.
    462     nonisolated static func assign(_ value: JournalValue, to entity: JournalEntity, gameID: UUID) {
    463         entity.gameID = gameID
    464         entity.seq = value.seq
    465         entity.timestamp = value.timestamp
    466         entity.row = Int16(value.position.row)
    467         entity.col = Int16(value.position.col)
    468         entity.beforeLetter = value.beforeState?.letter
    469         entity.beforeMarkCode = value.beforeState.map { NSNumber(value: $0.mark.code) }
    470         entity.beforeCellAuthorID = value.beforeState?.cellAuthorID
    471         entity.letter = value.state.letter
    472         entity.markCode = value.state.mark.code
    473         entity.cellAuthorID = value.state.cellAuthorID
    474         entity.actingAuthorID = value.actingAuthorID
    475         entity.kind = value.kind.rawValue
    476         entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) }
    477         entity.batchID = value.batchID
    478         entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) }
    479         entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) }
    480     }
    481 
    482     /// Maps a persisted row to its in-memory value. `internal` (not `private`)
    483     /// so `RecordBuilder` can reconstruct the upload asset straight from Core
    484     /// Data on its own background context.
    485     nonisolated static func value(from entity: JournalEntity) -> JournalValue {
    486         JournalValue(
    487             seq: entity.seq,
    488             timestamp: entity.timestamp ?? .distantPast,
    489             position: GridPosition(row: Int(entity.row), col: Int(entity.col)),
    490             beforeState: entity.beforeLetter.map {
    491                 JournalCellState(
    492                     letter: $0,
    493                     mark: CellMark(code: entity.beforeMarkCode?.int16Value ?? 0),
    494                     cellAuthorID: entity.beforeCellAuthorID
    495                 )
    496             },
    497             state: JournalCellState(
    498                 letter: entity.letter ?? "",
    499                 mark: CellMark(code: entity.markCode),
    500                 cellAuthorID: entity.cellAuthorID
    501             ),
    502             actingAuthorID: entity.actingAuthorID,
    503             kind: JournalKind(rawValue: entity.kind) ?? .input,
    504             targetSeq: entity.targetSeq?.int64Value,
    505             batchID: entity.batchID,
    506             prevSeqAtCell: entity.prevSeqAtCell?.int64Value,
    507             direction: entity.dir.map { $0.int16Value == 1 ? Puzzle.Direction.down : .across }
    508         )
    509     }
    510 }
    511 
    512 /// Wire format for a device's journal, encoded once at game completion and
    513 /// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful
    514 /// dump of `[JournalValue]` in `seq` order — merging every device's decoded
    515 /// dump by `timestamp` reconstructs the whole game for replay. The mark is
    516 /// carried as the single lossless `markCode` (`CellMark.code`).
    517 enum JournalCodec {
    518     /// Upper bound on one device's encoded `entries` blob. Each entry is a
    519     /// single keystroke at ~200 bytes of JSON, so 4 MiB is over 20,000 moves —
    520     /// far beyond any real solve — while keeping a peer-controlled asset from
    521     /// forcing an arbitrarily large read and decode. Callers that read the
    522     /// blob from a `CKAsset` file must also gate on the on-disk size *before*
    523     /// loading it (`RecordSerializer.boundedAssetData`); this bound caps the
    524     /// decode work itself.
    525     static let maxAssetBytes = 4_194_304
    526 
    527     /// Upper bound on decoded entries per device journal. Redundant with the
    528     /// byte cap for honest JSON, but bounds the `JournalValue` allocation and
    529     /// every later per-entry merge/replay pass independently of encoding
    530     /// tricks.
    531     static let maxEntryCount = 20_000
    532 
    533     /// A journal blob that exceeds the decode bounds. The whole blob is
    534     /// rejected (not truncated): a partial journal would silently corrupt the
    535     /// merged replay timeline, and the caller already treats a missing device
    536     /// journal as "replay unavailable".
    537     enum LimitError: Error, CustomStringConvertible {
    538         case oversized(bytes: Int)
    539         case tooManyEntries(count: Int)
    540 
    541         var description: String {
    542             switch self {
    543             case .oversized(let bytes):
    544                 return "journal blob exceeds \(maxAssetBytes) bytes (\(bytes))"
    545             case .tooManyEntries(let count):
    546                 return "journal blob exceeds \(maxEntryCount) entries (\(count))"
    547             }
    548         }
    549     }
    550 
    551     struct Payload: Codable, Equatable {
    552         struct Entry: Codable, Equatable {
    553             let seq: Int64
    554             let timestamp: Date
    555             let row: Int
    556             let col: Int
    557             let letter: String
    558             let markCode: Int16
    559             let cellAuthorID: String?
    560             let actingAuthorID: String?
    561             let kind: Int16
    562             let targetSeq: Int64?
    563             let batchID: UUID?
    564             let prevSeqAtCell: Int64?
    565             let dir: Int?
    566 
    567             init(
    568                 seq: Int64,
    569                 timestamp: Date,
    570                 row: Int,
    571                 col: Int,
    572                 letter: String,
    573                 markCode: Int16,
    574                 cellAuthorID: String?,
    575                 actingAuthorID: String?,
    576                 kind: Int16,
    577                 targetSeq: Int64?,
    578                 batchID: UUID?,
    579                 prevSeqAtCell: Int64?,
    580                 dir: Int?
    581             ) {
    582                 self.seq = seq
    583                 self.timestamp = timestamp
    584                 self.row = row
    585                 self.col = col
    586                 self.letter = letter
    587                 self.markCode = markCode
    588                 self.cellAuthorID = cellAuthorID
    589                 self.actingAuthorID = actingAuthorID
    590                 self.kind = kind
    591                 self.targetSeq = targetSeq
    592                 self.batchID = batchID
    593                 self.prevSeqAtCell = prevSeqAtCell
    594                 self.dir = dir
    595             }
    596 
    597             // Optionals are decoded leniently so a record written by a newer
    598             // client that added fields still decodes cleanly on an older one
    599             // (same forward-compat stance as `MovesCodec.Payload.Entry`).
    600             init(from decoder: Decoder) throws {
    601                 let c = try decoder.container(keyedBy: CodingKeys.self)
    602                 seq = try c.decode(Int64.self, forKey: .seq)
    603                 timestamp = try c.decode(Date.self, forKey: .timestamp)
    604                 row = try c.decode(Int.self, forKey: .row)
    605                 col = try c.decode(Int.self, forKey: .col)
    606                 letter = try c.decode(String.self, forKey: .letter)
    607                 markCode = try c.decode(Int16.self, forKey: .markCode)
    608                 kind = try c.decode(Int16.self, forKey: .kind)
    609                 cellAuthorID = try? c.decode(String.self, forKey: .cellAuthorID)
    610                 actingAuthorID = try? c.decode(String.self, forKey: .actingAuthorID)
    611                 targetSeq = try? c.decode(Int64.self, forKey: .targetSeq)
    612                 batchID = try? c.decode(UUID.self, forKey: .batchID)
    613                 prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell)
    614                 dir = try? c.decode(Int.self, forKey: .dir)
    615             }
    616         }
    617         let entries: [Entry]
    618     }
    619 
    620     static func encode(_ values: [JournalValue]) throws -> Data {
    621         let entries = values
    622             .sorted { $0.seq < $1.seq }
    623             .map { value in
    624                 Payload.Entry(
    625                     seq: value.seq,
    626                     timestamp: value.timestamp,
    627                     row: value.position.row,
    628                     col: value.position.col,
    629                     letter: value.state.letter,
    630                     markCode: value.state.mark.code,
    631                     cellAuthorID: value.state.cellAuthorID,
    632                     actingAuthorID: value.actingAuthorID,
    633                     kind: value.kind.rawValue,
    634                     targetSeq: value.targetSeq,
    635                     batchID: value.batchID,
    636                     prevSeqAtCell: value.prevSeqAtCell,
    637                     dir: value.direction?.rawValue
    638                 )
    639             }
    640         return try JSONEncoder().encode(Payload(entries: entries))
    641     }
    642 
    643     static func decode(_ data: Data) throws -> [JournalValue] {
    644         guard data.count <= maxAssetBytes else {
    645             throw LimitError.oversized(bytes: data.count)
    646         }
    647         let payload = try JSONDecoder().decode(Payload.self, from: data)
    648         guard payload.entries.count <= maxEntryCount else {
    649             throw LimitError.tooManyEntries(count: payload.entries.count)
    650         }
    651         return payload.entries.compactMap { entry in
    652             let position = GridPosition(row: entry.row, col: entry.col)
    653             // A remote journal's coordinates are attacker-controlled; entries
    654             // that can't survive `assign`'s Int16 conversions are dropped here
    655             // so they never reach the replay cache.
    656             guard position.isInt16Representable else { return nil }
    657             return JournalValue(
    658                 seq: entry.seq,
    659                 timestamp: entry.timestamp,
    660                 position: position,
    661                 beforeState: nil,
    662                 state: JournalCellState(
    663                     letter: entry.letter,
    664                     mark: CellMark(code: entry.markCode),
    665                     cellAuthorID: entry.cellAuthorID
    666                 ),
    667                 actingAuthorID: entry.actingAuthorID,
    668                 kind: JournalKind(rawValue: entry.kind) ?? .input,
    669                 targetSeq: entry.targetSeq,
    670                 batchID: entry.batchID,
    671                 prevSeqAtCell: entry.prevSeqAtCell,
    672                 direction: entry.dir.flatMap(Puzzle.Direction.init(rawValue:))
    673             )
    674         }
    675     }
    676 }