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 }