field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraComposerAutosave.swift (12280B)


      1 import Foundation
      2 
      3 /// A transient write barrier; its token is never a durable submission identity.
      4 struct TeraComposerCapture: Sendable, Equatable {
      5   let token: UUID
      6   let scope: TeraComposerScope
      7   let form: TeraComposerForm
      8   let editSequence: UInt64
      9 }
     10 
     11 /// A single worker retains one attempted write and the newest edit. Receipts
     12 /// update acknowledgment metadata only; they never replace the editing form.
     13 @MainActor
     14 final class TeraComposerAutosave {
     15   private(set) var state: TeraComposerSaveState = .idle {
     16     didSet {
     17       if state != oldValue {
     18         stateChanged(state)
     19       }
     20     }
     21   }
     22 
     23   var stateChanged: (TeraComposerSaveState) -> Void = { _ in }
     24   private(set) var acknowledged: TeraComposerDraft?
     25   private(set) var scope: TeraComposerScope?
     26   private(set) var editSequence: UInt64 = 0
     27   private(set) var id: String?
     28   private let persistence: TeraComposerPersistence
     29   private let delay: @Sendable () async throws -> Void
     30   private var generation = TeraSessionGeneration.initial
     31   private var current: TeraComposerForm?
     32   private var attempted: TeraComposerSaveRequest?
     33   private var worker: Task<Void, Never>?
     34   private var coalescingTask: Task<Void, Error>?
     35   private var immediateSave = false
     36   private var lastFailure: Error?
     37   private var paused = false
     38   private var exhausted = false
     39   private var capture: TeraComposerCapture?
     40 
     41   init(
     42     persistence: TeraComposerPersistence,
     43     delay: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(250)) }
     44   ) {
     45     self.persistence = persistence
     46     self.delay = delay
     47   }
     48 
     49   deinit {
     50     coalescingTask?.cancel()
     51     worker?.cancel()
     52   }
     53 
     54   var isDirty: Bool {
     55     current != nil && (exhausted || acknowledged?.editSequence != editSequence || acknowledged?.form != current)
     56   }
     57 
     58   private var needsWrite: Bool {
     59     if let capture {
     60       return !matchesCapture(acknowledged, capture)
     61     }
     62     return isDirty
     63   }
     64 
     65   private func matchesCapture(_ draft: TeraComposerDraft?, _ value: TeraComposerCapture) -> Bool {
     66     draft?.scope == value.scope && draft?.editSequence == value.editSequence && draft?.form == value.form
     67   }
     68 
     69   /// A replaced scope or New operation invalidates callbacks, but keeps the
     70   /// worker slot occupied until it exits, preventing abandoned task fan-out.
     71   func reset(scope: TeraComposerScope?) {
     72     generation = generation.invalidated()
     73     immediateSave = false
     74     coalescingTask?.cancel()
     75     worker?.cancel()
     76     self.scope = scope
     77     id = nil
     78     current = nil
     79     acknowledged = nil
     80     attempted = nil
     81     lastFailure = nil
     82     editSequence = 0
     83     exhausted = false
     84     capture = nil
     85     paused = false
     86     state = .idle
     87   }
     88 
     89   func stop() {
     90     generation = generation.invalidated()
     91     paused = true
     92     immediateSave = false
     93     coalescingTask?.cancel()
     94     worker?.cancel()
     95     if isDirty {
     96       state = .unsaved
     97     }
     98   }
     99 
    100   func resume() {
    101     paused = false
    102     startWorker()
    103   }
    104 
    105   func restore(_ draft: TeraComposerDraft) throws {
    106     guard draft.scope == scope, TeraAddPresentation.isValidIdentifier(draft.id),
    107           draft.id != String(repeating: "0", count: 32), draft.revision > 0,
    108           draft.revision <= UInt64(Int64.max), draft.editSequence > 0
    109     else {
    110       throw TeraComposerAcknowledgment.unconfirmed
    111     }
    112     reset(scope: draft.scope)
    113     id = draft.id
    114     editSequence = draft.editSequence
    115     current = draft.form
    116     acknowledged = draft
    117     state = .saved
    118   }
    119 
    120   func change(_ form: TeraComposerForm) {
    121     guard form != current else { return }
    122     current = form
    123     let (next, overflow) = editSequence.addingReportingOverflow(1)
    124     guard !overflow, !exhausted else {
    125       exhausted = true
    126       state = .failed
    127       return
    128     }
    129     editSequence = next
    130     if state != .failed {
    131       state = .unsaved
    132     }
    133     startWorker()
    134   }
    135 
    136   func observeEditing(_ form: TeraAddForm, isEditable: Bool, isRevision: Bool) {
    137     guard isEditable, !isRevision else {
    138       stop()
    139       state = isRevision ? .revision : .idle
    140       return
    141     }
    142     change(TeraComposerForm(editing: form))
    143   }
    144 
    145   func save(_ form: TeraComposerForm) async throws -> TeraComposerDraft {
    146     guard !Task.isCancelled else { throw CancellationError() }
    147     change(form)
    148     guard capture == nil else {
    149       throw TeraRuntimeFailure.local(operation: "composer.save", code: "submission_capture_pending",
    150                                      safeMessage: "The original submission must be reconciled before newer editing can be saved.")
    151     }
    152     guard !paused, !exhausted, generation.isActive, scope != nil else {
    153       state = .failed
    154       throw TeraComposerAcknowledgment.unconfirmed
    155     }
    156     let requested = generation
    157     if state == .failed {
    158       state = .unsaved
    159     }
    160     if isDirty {
    161       immediateSave = true
    162       coalescingTask?.cancel()
    163     }
    164     startWorker()
    165     while let task = worker {
    166       if isDirty {
    167         immediateSave = true
    168         coalescingTask?.cancel()
    169       }
    170       await task.value
    171       try ensureCurrent(requested)
    172     }
    173     guard !isDirty, let acknowledged else { throw lastFailure ?? TeraComposerAcknowledgment.unconfirmed }
    174     try await confirmSaved(acknowledged, generation: requested)
    175     guard !isDirty else { throw TeraComposerAcknowledgment.unconfirmed }
    176     return acknowledged
    177   }
    178 
    179   private func startWorker() {
    180     guard worker == nil, !paused, !exhausted, generation.isActive, scope != nil,
    181           needsWrite, state != .failed else { return }
    182     let requested = generation
    183     lastFailure = nil
    184     worker = Task { [weak self] in
    185       guard let self else { return }
    186       await run(requested)
    187     }
    188   }
    189 
    190   private func run(_ requested: TeraSessionGeneration) async {
    191     defer {
    192       if generation == requested {
    193         immediateSave = false
    194       }
    195       worker = nil
    196       startWorker()
    197     }
    198     do {
    199       while needsWrite {
    200         try ensureCurrent(requested)
    201         state = .saving
    202         try await coalesce(requested)
    203         try ensureCurrent(requested)
    204         try await reconcileAttempt(requested)
    205         try await persistCurrent(requested)
    206       }
    207       try ensureCurrent(requested)
    208       state = isDirty ? .unsaved : .saved
    209     } catch {
    210       // A write may have committed before cancellation or a lost callback.
    211       // Keep its exact request for a read before any retry in this lifetime.
    212       if generation == requested, !paused {
    213         lastFailure = error
    214         state = .failed
    215       }
    216     }
    217   }
    218 
    219   private func coalesce(_ requested: TeraSessionGeneration) async throws {
    220     guard !immediateSave else { return }
    221     let task = Task { [delay] in try await delay() }
    222     coalescingTask = task
    223     defer { coalescingTask = nil }
    224     do {
    225       try await task.value
    226     } catch {
    227       try ensureCurrent(requested)
    228       // Explicit Save cancels only batching; an in-flight write still owns
    229       // its slot until its receipt or unknown-write reconciliation completes.
    230       guard immediateSave, error is CancellationError else { throw error }
    231     }
    232   }
    233 
    234   private func persistCurrent(_ requested: TeraSessionGeneration) async throws {
    235     if id == nil {
    236       let reserved = try await persistence.reserve()
    237       try ensureCurrent(requested)
    238       guard TeraAddPresentation.isValidIdentifier(reserved), reserved != String(repeating: "0", count: 32) else {
    239         throw TeraComposerAcknowledgment.unconfirmed
    240       }
    241       id = reserved
    242     }
    243     guard needsWrite, let id, let scope, let current else { return }
    244     let request = TeraComposerSaveRequest(scope: scope, id: id, expectedRevision: acknowledged?.revision,
    245                                           editSequence: capture?.editSequence ?? editSequence, form: capture?.form ?? current)
    246     attempted = request
    247     let receipt = try await persistence.save(request)
    248     try ensureCurrent(requested)
    249     guard TeraComposerAcknowledgment.matches(receipt.draft, request: request) else {
    250       throw TeraComposerAcknowledgment.unconfirmed
    251     }
    252     acknowledged = receipt.draft
    253     attempted = nil
    254   }
    255 
    256   private func reconcileAttempt(_ requested: TeraSessionGeneration) async throws {
    257     guard let attempted else { return }
    258     let loaded: TeraComposerDraft
    259     do {
    260       loaded = try await persistence.load(attempted.scope, attempted.id)
    261     } catch {
    262       try ensureCurrent(requested)
    263       if attempted.expectedRevision == nil,
    264          TeraAddPresentation.failure(for: error)?.code == "composer_not_found"
    265       {
    266         self.attempted = nil
    267         return
    268       }
    269       throw error
    270     }
    271     try ensureCurrent(requested)
    272     if TeraComposerAcknowledgment.matches(loaded, request: attempted) {
    273       acknowledged = loaded
    274     } else if loaded != acknowledged {
    275       // A different writer won the CAS. Preserve this form for recovery;
    276       // silently adopting its revision would overwrite someone else's edit.
    277       throw TeraComposerAcknowledgment.unconfirmed
    278     }
    279     self.attempted = nil
    280   }
    281 
    282   private func ensureCurrent(_ requested: TeraSessionGeneration) throws {
    283     guard generation == requested, generation.isActive, !paused, !exhausted, !Task.isCancelled else {
    284       throw CancellationError()
    285     }
    286   }
    287 }
    288 
    289 /// Capture scheduling shares the private autosave worker; it owns no second persistence path.
    290 extension TeraComposerAutosave {
    291   private func confirmSaved(_ draft: TeraComposerDraft, generation requested: TeraSessionGeneration) async throws {
    292     do {
    293       try await persistence.confirm(draft)
    294       try ensureCurrent(requested)
    295       guard acknowledged == draft else { throw TeraComposerAcknowledgment.unconfirmed }
    296       if !isDirty {
    297         state = .saved
    298       }
    299     } catch {
    300       if generation == requested {
    301         state = .failed
    302         lastFailure = error
    303       }
    304       throw error
    305     }
    306   }
    307 
    308   var hasSubmissionCapture: Bool {
    309     capture != nil
    310   }
    311 
    312   /// Reserve synchronously with the tap, before either worker can await.
    313   func beginSubmissionCapture(_ form: TeraComposerForm) throws -> TeraComposerCapture {
    314     guard capture == nil, !paused, !exhausted, generation.isActive, let scope else {
    315       throw TeraComposerAcknowledgment.unconfirmed
    316     }
    317     change(form)
    318     guard !exhausted else { throw TeraComposerAcknowledgment.unconfirmed }
    319     let value = TeraComposerCapture(token: UUID(), scope: scope, form: form, editSequence: editSequence)
    320     capture = value
    321     if state == .failed {
    322       state = .unsaved
    323     }
    324     immediateSave = true
    325     coalescingTask?.cancel()
    326     startWorker()
    327     return value
    328   }
    329 
    330   /// Reconcile an existing write, then persist only this captured edit. Later
    331   /// edits remain visible and unsaved until the caller releases the barrier.
    332   func saveSubmissionCapture(_ value: TeraComposerCapture) async throws -> TeraComposerDraft {
    333     guard capture == value else { throw TeraComposerAcknowledgment.unconfirmed }
    334     let requested = generation
    335     try ensureCurrent(requested)
    336     if state == .failed {
    337       state = .unsaved
    338     }
    339     immediateSave = true
    340     coalescingTask?.cancel()
    341     startWorker()
    342     while let task = worker {
    343       await task.value
    344       try ensureCurrent(requested)
    345     }
    346     guard capture == value, let acknowledged, matchesCapture(acknowledged, value) else {
    347       throw lastFailure ?? TeraComposerAcknowledgment.unconfirmed
    348     }
    349     try await confirmSaved(acknowledged, generation: requested)
    350     return acknowledged
    351   }
    352 
    353   /// Call only after submission commit/recovery is confirmed, or an explicit
    354   /// decision abandons a request known not to have committed.
    355   func releaseSubmissionCapture(_ value: TeraComposerCapture) {
    356     guard capture == value else { return }
    357     capture = nil
    358     if isDirty {
    359       state = .unsaved
    360       immediateSave = true
    361       startWorker()
    362     }
    363   }
    364 
    365   /// A confirmed Rust reservation owns the original source head. Continue
    366   /// editing through the same persistence worker under a fresh composer ID;
    367   /// never overwrite the revision required by the original prepare CAS.
    368   func continueEditing(after value: TeraComposerCapture, reserved: TeraComposerDraft) throws {
    369     guard capture == value, acknowledged == reserved, matchesCapture(reserved, value),
    370           let current, worker == nil, attempted == nil
    371     else {
    372       throw TeraComposerAcknowledgment.unconfirmed
    373     }
    374     reset(scope: value.scope)
    375     change(current)
    376   }
    377 }