field_ios

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

TeraAddStore.swift (26704B)


      1 import Foundation
      2 
      3 @MainActor
      4 final class TeraAddStore: ObservableObject {
      5   @Published private(set) var schemas: [TeraAddSchema] = []
      6   @Published private(set) var drafts: [TeraDraftStatus] = []
      7   @Published private(set) var activeDraft: TeraDraftStatus?
      8   @Published private(set) var form: TeraAddForm {
      9     didSet {
     10       if form != oldValue {
     11         message = nil
     12         protection.editingChanged()
     13         composer.observeEditing(form, isEditable: isFormEditable, isRevision: revisionTarget != nil)
     14       }
     15     }
     16   }
     17 
     18   @Published private(set) var composerState: TeraComposerSaveState = .idle
     19   let submissions: TeraSubmissionStore
     20   let recovery: TeraDraftRecoveryStore
     21   let protection = TeraEditingProtection()
     22   @Published private(set) var mediaRecoveryMessage: String?
     23   @Published private(set) var state: TeraAddLoadState = .idle
     24   var mediaSupport: TeraAddMediaSupport {
     25     mediaAccess.support
     26   }
     27 
     28   private let mediaAccess: TeraMediaAccessStore
     29   @Published private(set) var blossomConfiguration: TeraBlossomConfigurationStatus?
     30   @Published private(set) var blossomEvidence: TeraBlossomEvidence?
     31   @Published private(set) var isCheckingBlossom = false
     32   @Published private(set) var isWorking = false
     33   @Published private(set) var message: String?
     34   @Published private(set) var lastFailureCode: String?
     35   @Published private(set) var observationState: TeraRuntimeObservationState = .inactive
     36 
     37   private let runtimeClient: TeraRuntimeClient
     38   private let composer: TeraComposerAutosave
     39   private let media: (any TeraAddMediaHandling)?
     40   private let observationDelay: @Sendable (UInt32) async throws -> Void
     41   private let identifier: @Sendable () -> String
     42   private let clock: TeraClock
     43   private var generation = TeraSessionGeneration.initial
     44   private var operationGeneration: TeraSessionGeneration?
     45   private var operationTask: Task<Void, Never>?
     46   private let observation = TeraStoreObservation()
     47   private var configuration: TeraPresentationConfiguration?
     48   private var draftsGeneration = TeraSessionGeneration.initial
     49   private var appliedDraftsGeneration = TeraSessionGeneration.initial
     50   private var probeGeneration = TeraSessionGeneration.initial
     51   private var blossomGeneration = TeraSessionGeneration.initial
     52   let revisionPreparation: TeraRevisionPreparation
     53   private var revisionTarget: TeraRevisionTarget?
     54   private var revisionOperationID: String?
     55   private var activePublicKey: String?
     56 
     57   init(
     58     runtimeClient: TeraRuntimeClient,
     59     media: (any TeraAddMediaHandling)? = nil,
     60     initialType: TeraAddCommandType = .createUpdate,
     61     identifier: @escaping @Sendable () -> String = {
     62       UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
     63     },
     64     clock: TeraClock = .system,
     65     observationDelay: @escaping @Sendable (UInt32) async throws -> Void =
     66       TeraRuntimeObservationBackoff.sleep
     67   ) {
     68     self.runtimeClient = runtimeClient
     69     revisionPreparation = TeraRevisionPreparation(client: runtimeClient, media: media)
     70     recovery = TeraDraftRecoveryStore(client: runtimeClient, media: media)
     71     composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient).protectingMedia(media))
     72     submissions = TeraSubmissionStore(client: runtimeClient, composer: composer, media: media)
     73     self.media = media
     74     mediaAccess = TeraMediaAccessStore(media: media)
     75     self.identifier = identifier
     76     self.clock = clock
     77     self.observationDelay = observationDelay
     78     form = TeraAddPresentation.newForm(
     79       type: initialType,
     80       identifier: identifier,
     81       clock: clock
     82     )
     83     mediaAccess.changed = { [weak self] in self?.objectWillChange.send() }
     84     submissions.changed = { [weak self] in self?.objectWillChange.send() }
     85     composer.stateChanged = { [weak self] in self?.composerState = $0 }
     86     protection.cancelled = { [weak self] in guard let self else { return }; generation = generation.invalidated() }
     87   }
     88 
     89   var savedComposer: TeraComposerDraft? {
     90     composer.acknowledged
     91   }
     92 
     93   func updateForm<Value>(_ keyPath: WritableKeyPath<TeraAddForm, Value>, _ value: Value) {
     94     guard isFormEditable else { return }
     95     generation = generation.invalidated()
     96     form[keyPath: keyPath] = value
     97   }
     98 
     99   func newDraft(type: TeraAddCommandType? = nil) {
    100     guard canCreateNewComposer else { return }
    101     if needsEditingPreservation {
    102       protection.schedule(kind: .editing, save: preservation(), apply: replacement { store, _ in store.replaceWithNew(type: type) })
    103     } else {
    104       replaceWithNew(type: type)
    105     }
    106   }
    107 
    108   private func replaceWithNew(type: TeraAddCommandType?) {
    109     guard submissions.canReplaceEditing else { return }
    110     submissions.newAction()
    111     composer.reset(scope: composer.scope)
    112     generation = generation.invalidated()
    113     activeDraft = nil
    114     resetRevision()
    115     form = TeraAddPresentation.newForm(type: type ?? form.commandType, identifier: identifier, clock: clock)
    116     message = nil
    117   }
    118 
    119   func reopen(_ draft: TeraDraftStatus) {
    120     guard !isWorking, !submissions.isWorking, !protection.isWorking, !protection.failed else { return }
    121     if needsEditingPreservation {
    122       protection.schedule(kind: .reopen, save: preservation(), apply: replacement { store, _ in store.replaceWithLegacy(draft) })
    123     } else {
    124       replaceWithLegacy(draft)
    125     }
    126   }
    127 
    128   private func replaceWithLegacy(_ draft: TeraDraftStatus) {
    129     guard !submissions.isWorking else { return }
    130     guard let snapshot = draft.form else {
    131       message = "This operation has no editable Add form."
    132       return
    133     }
    134     generation = generation.invalidated()
    135     activeDraft = draft
    136     composer.reset(scope: composer.scope)
    137     revisionTarget = nil
    138     revisionPreparation.reset()
    139     revisionOperationID = draft.isRevision ? draft.id : nil
    140     form = snapshot
    141     message = draft.isEditable ? "Draft reopened." : draft.honestSummary
    142   }
    143 
    144   func reopenSaved(_ selection: TeraDraftRecoverySelection) async -> Bool {
    145     await replaceEditing(kind: .reopen) { store, requested in
    146       let recovered = try await store.recovery.load(selection)
    147       try store.ensureCurrent(requested)
    148       switch recovered {
    149       case let .composer(draft):
    150         try store.composer.restore(draft)
    151         store.activeDraft = nil
    152         store.resetRevision()
    153         store.form = draft.form.editingValue
    154         store.message = "Saved editing reopened."
    155       case let .legacy(draft):
    156         store.composer.reset(scope: store.composer.scope)
    157         store.revisionTarget = nil
    158         store.revisionPreparation.reset()
    159         store.revisionOperationID = draft.isRevision ? draft.id : nil
    160         try store.accept(draft, generation: requested)
    161         store.message = draft.honestSummary
    162       }
    163     }
    164   }
    165 
    166   func importPhotos() async {
    167     await prepareMedia(capture: false)
    168   }
    169 
    170   private func prepareMedia(capture: Bool) async {
    171     guard canAddMedia, media != nil else {
    172       message = "Photo intake is unavailable."
    173       return
    174     }
    175     await perform { requestedGeneration in
    176       let remaining = self.mediaLimit - self.form.media.count
    177       let prepared = try await self.mediaAccess.prepare(limit: remaining, capture: capture)
    178       try self.ensureCurrent(requestedGeneration)
    179       self.form.media.append(contentsOf: prepared.prefix(remaining))
    180       self.message = "Photo prepared. Add descriptive text before publishing."
    181     }
    182   }
    183 
    184   func checkPhotoService() async {
    185     guard !isCheckingBlossom, !Task.isCancelled else { return }
    186     guard blossomConfiguration != nil else {
    187       message = "No photo service is configured for the current network profile."
    188       return
    189     }
    190     probeGeneration = probeGeneration.invalidated()
    191     let probe = probeGeneration
    192     blossomGeneration = blossomGeneration.invalidated()
    193     let serviceRequest = blossomGeneration
    194     let requestedGeneration = generation
    195     isCheckingBlossom = true
    196     defer {
    197       if probe == probeGeneration {
    198         isCheckingBlossom = false
    199       }
    200     }
    201     do {
    202       let evidence = try await runtimeClient.probeBlossom()
    203       try ensureCurrent(requestedGeneration)
    204       let support = try await mediaAccess.inspect()
    205       try ensureCurrent(requestedGeneration)
    206       guard probe == probeGeneration, serviceRequest == blossomGeneration else { return }
    207       blossomEvidence = evidence
    208       mediaAccess.accept(support)
    209       message = "Photo service is reachable."
    210     } catch {
    211       guard isCurrent(requestedGeneration), probe == probeGeneration,
    212             serviceRequest == blossomGeneration else { return }
    213       guard await refreshBlossomSnapshot(), isCurrent(requestedGeneration),
    214             probe == probeGeneration else { return }
    215       message = TeraAddPresentation.message(for: error)
    216     }
    217   }
    218 
    219   func removeMedia(id: String) {
    220     guard isFormEditable else { return }
    221     generation = generation.invalidated()
    222     form.media.removeAll(where: { $0.id == id })
    223   }
    224 
    225   func updateMediaAlt(id: String, alt: String) {
    226     guard isFormEditable,
    227       let index = form.media.firstIndex(where: { $0.id == id })
    228     else { return }
    229     generation = generation.invalidated()
    230     form.media[index].alt = alt
    231   }
    232 
    233   func save(locale: Locale = .current) async {
    234     if canSave, !protection.isWorking, revisionTarget != nil, isFormEditable {
    235       form = TeraFoodDecimalEntry.form(form, locale: locale)
    236     }
    237     await perform { requestedGeneration in
    238       if self.revisionTarget != nil {
    239         _ = try await self.saveCurrentForm(generation: requestedGeneration)
    240       } else {
    241         guard self.isFormEditable else { throw TeraComposerAcknowledgment.unconfirmed }
    242         _ = try await self.composer.save(TeraComposerForm(editing: self.form))
    243       }
    244       try self.ensureCurrent(requestedGeneration)
    245       self.message = "Draft saved on this device."
    246     }
    247   }
    248 
    249   func submit(locale: Locale = .current) async {
    250     // Only a new editable request may translate entry text. Existing capture,
    251     // retry, revision operation and signed bytes retain their original inputs.
    252     if canSubmit, !protection.isWorking, isFormEditable, !submissions.usesCurrentAction, activeDraft?.isRevision != true {
    253       form = TeraFoodDecimalEntry.form(form, locale: locale)
    254     }
    255     if activeDraft == nil, revisionTarget == nil {
    256       guard canSubmit else { return }
    257       await submissions.submit(form: form)
    258       return
    259     }
    260     await perform { requested in
    261       let legacy = TeraLegacySubmission(
    262         runtimeClient: self.runtimeClient, media: self.media,
    263         revisionID: { self.revisionOperationID },
    264         initial: {
    265           if let active = self.activeDraft, active.isRevision || !active.isEditable {
    266             return active
    267           }
    268           return try await self.saveCurrentForm(generation: requested)
    269         },
    270         ensure: { try self.ensureCurrent(requested) },
    271         acceptDraft: { try self.accept($0, generation: requested) },
    272         acceptRevision: { try self.accept($0, generation: requested) },
    273         message: { self.message = $0 },
    274         refreshMedia: { await self.refreshBlossomSnapshot() }
    275       )
    276       try await legacy.submit()
    277     }
    278   }
    279 
    280   func retry(id: String) async {
    281     await replaceEditing { store, requestedGeneration in
    282       var current = try await store.runtimeClient.draftStatus(id: id)
    283       try store.ensureCurrent(requestedGeneration)
    284       guard current.coordinateWritable else {
    285         store.message = current.honestSummary
    286         return
    287       }
    288       if current.state == .draft || current.state == .mediaPreparing
    289         || current.state == .readyToSign
    290       {
    291         store.composer.reset(scope: store.composer.scope)
    292         store.activeDraft = current
    293         store.revisionOperationID = current.isRevision ? current.id : nil
    294         if let form = current.form {
    295           store.form = form
    296         }
    297         store.message = "Review the draft before submitting again."
    298         return
    299       }
    300       if current.isRevision {
    301         let revision = try await store.runtimeClient.advanceRevision(
    302           operationID: current.id
    303         )
    304         try store.ensureCurrent(requestedGeneration)
    305         store.composer.reset(scope: store.composer.scope)
    306         try store.accept(revision, generation: requestedGeneration)
    307         store.message = revision.honestSummary
    308         return
    309       }
    310       if current.canAdvance {
    311         current = try await store.runtimeClient.advanceDraft(
    312           id: current.id,
    313           expectedRevision: current.revision
    314         )
    315       }
    316       try store.ensureCurrent(requestedGeneration)
    317       store.composer.reset(scope: store.composer.scope)
    318       try store.accept(current, generation: requestedGeneration)
    319       store.message = current.honestSummary
    320     }
    321   }
    322 
    323   func cancel(id: String) async {
    324     await replaceEditing { store, requestedGeneration in
    325       let current = try await store.runtimeClient.draftStatus(id: id)
    326       try store.ensureCurrent(requestedGeneration)
    327       guard current.state.canCancel else { return }
    328       if current.isRevision {
    329         let cancelled = try await store.runtimeClient.cancelRevision(
    330           operationID: current.id
    331         )
    332         try store.ensureCurrent(requestedGeneration)
    333         store.composer.reset(scope: store.composer.scope)
    334         try store.accept(cancelled, generation: requestedGeneration)
    335         store.message = cancelled.honestSummary
    336         return
    337       }
    338       let cancelled = try await store.runtimeClient.cancelAddIntent(
    339         id: current.id,
    340         expectedRevision: current.revision
    341       )
    342       try store.ensureCurrent(requestedGeneration)
    343       store.composer.reset(scope: store.composer.scope)
    344       try store.accept(cancelled, generation: requestedGeneration)
    345       store.message = "Local work was cancelled. Any already-published relay effect is preserved."
    346     }
    347   }
    348 
    349   func retractAndRevise(_ card: TeraTodayCard) async {
    350     await replaceEditing { store, requestedGeneration in
    351       let editing = try await TeraAddCardIntents.revision(card, author: store.activePublicKey, client: store.runtimeClient)
    352       try store.ensureCurrent(requestedGeneration)
    353       store.revisionTarget = editing.target
    354       store.revisionPreparation.reset()
    355       store.composer.reset(scope: store.composer.scope)
    356       store.revisionOperationID = nil
    357       store.activeDraft = nil
    358       store.form = editing.form
    359       store.message = "Review the lossless revised copy before publishing."
    360     }
    361   }
    362 
    363   func retract(_ card: TeraTodayCard) async {
    364     await replaceEditing { store, requestedGeneration in
    365       let request = try TeraAddCardIntents.retraction(card, author: store.activePublicKey, identifier: store.identifier)
    366       var status = try await store.runtimeClient.saveRetractionDraft(
    367         id: request.id, input: request.input,
    368         authoredAtUnixSeconds: store.clock.unixSeconds(),
    369         persistedAtUnixMilliseconds: store.clock.unixMilliseconds()
    370       )
    371       try store.ensureCurrent(requestedGeneration)
    372       store.composer.reset(scope: store.composer.scope)
    373       try store.accept(status, generation: requestedGeneration)
    374       status = try await store.runtimeClient.queueAddIntent(
    375         id: status.id,
    376         expectedRevision: status.revision
    377       )
    378       try store.accept(status, generation: requestedGeneration)
    379       if status.canAdvance {
    380         status = try await store.runtimeClient.advanceDraft(
    381           id: status.id,
    382           expectedRevision: status.revision
    383         )
    384         try store.accept(status, generation: requestedGeneration)
    385       }
    386       store.message = status.honestSummary
    387     }
    388   }
    389 
    390   private func reloadDrafts() async {
    391     let requestedGeneration = generation
    392     draftsGeneration = draftsGeneration.invalidated()
    393     let request = draftsGeneration
    394     do {
    395       let loaded = try await runtimeClient.draftHeads(limit: 100)
    396       guard isCurrent(requestedGeneration), request == draftsGeneration else { return }
    397       appliedDraftsGeneration = try appliedDraftsGeneration.next()
    398       drafts = TeraAddPresentation.sorted(loaded)
    399       if let activeDraft, let current = loaded.first(where: { $0.id == activeDraft.id }),
    400          current.revision >= activeDraft.revision
    401       {
    402         self.activeDraft = current
    403       }
    404     } catch {
    405       guard isCurrent(requestedGeneration), request == draftsGeneration else { return }
    406       message = TeraAddPresentation.message(for: error)
    407     }
    408   }
    409 
    410   @discardableResult
    411   private func refreshBlossomSnapshot() async -> Bool {
    412     let requestedGeneration = generation
    413     blossomGeneration = blossomGeneration.invalidated()
    414     let request = blossomGeneration
    415     let snapshot = try? await runtimeClient.snapshot()
    416     guard isCurrent(requestedGeneration), request == blossomGeneration else { return false }
    417     if let snapshot, configuration == TeraPresentationConfiguration(snapshot: snapshot) {
    418       blossomConfiguration = snapshot.blossomConfiguration
    419       blossomEvidence = snapshot.blossomEvidence
    420     }
    421     return true
    422   }
    423 
    424   private func startObservation() {
    425     observation.start(
    426       client: runtimeClient, buffer: (capacity: 16, delay: observationDelay),
    427       state: { [weak self] in self?.observationState = $0 },
    428       accepts: { [weak self] in $0.matches(context: self?.configuration?.context) },
    429       refresh: { [weak self] batch in
    430         guard let self else { return }
    431         let requested = generation
    432         if batch.contains(anyOf: [.drafts, .media]) {
    433           submissions.inventory.start()
    434           await reloadDrafts()
    435         }
    436         guard isCurrent(requested) else { return }
    437         if batch.contains(anyOf: [.media, .settings]) {
    438           await refreshBlossomSnapshot()
    439         }
    440       }
    441     )
    442   }
    443 
    444   private func isCurrent(_ requested: TeraSessionGeneration) -> Bool {
    445     requested == generation && generation.isActive && !Task.isCancelled
    446   }
    447 
    448   private func ensureCurrent(_ requested: TeraSessionGeneration) throws {
    449     guard isCurrent(requested) else { throw CancellationError() }
    450   }
    451 
    452   private func accept(_ status: TeraDraftStatus, generation requested: TeraSessionGeneration) throws {
    453     try ensureCurrent(requested)
    454     draftsGeneration = draftsGeneration.invalidated()
    455     appliedDraftsGeneration = try appliedDraftsGeneration.next()
    456     activeDraft = status
    457     if let form = status.form {
    458       self.form = form
    459     }
    460     drafts.removeAll(where: { $0.id == status.id })
    461     drafts.append(status)
    462     drafts = TeraAddPresentation.sorted(drafts)
    463   }
    464 
    465   private func accept(_ status: TeraRevisionStatus, generation requested: TeraSessionGeneration) throws {
    466     try ensureCurrent(requested)
    467     revisionOperationID = status.operationID
    468     try accept(status.replacement, generation: requested)
    469     if let retraction = status.retraction {
    470       drafts.removeAll(where: { $0.id == retraction.id })
    471       drafts.append(retraction)
    472       drafts = TeraAddPresentation.sorted(drafts)
    473     }
    474   }
    475 
    476   private func perform(_ operation: @escaping (TeraSessionGeneration) async throws -> Void) async {
    477     guard operationTask == nil, !Task.isCancelled else { return }
    478     generation = generation.invalidated()
    479     let requestedGeneration = generation
    480     operationGeneration = requestedGeneration
    481     isWorking = true
    482     message = nil
    483     lastFailureCode = nil
    484     let task = Task { @MainActor [weak self] in
    485       guard let self, !Task.isCancelled else { return }
    486       await execute(operation, generation: requestedGeneration)
    487     }
    488     operationTask = task
    489     await task.value
    490   }
    491 
    492   private func execute(
    493     _ operation: @escaping (TeraSessionGeneration) async throws -> Void,
    494     generation requestedGeneration: TeraSessionGeneration
    495   ) async {
    496     defer {
    497       if operationGeneration == requestedGeneration {
    498         isWorking = false
    499         operationGeneration = nil
    500         operationTask = nil
    501       }
    502     }
    503     do {
    504       try ensureCurrent(requestedGeneration)
    505       try await operation(requestedGeneration)
    506     } catch is CancellationError {
    507       if isCurrent(requestedGeneration) {
    508         message = TeraUserMessages.text(.operationCancelled)
    509       }
    510     } catch {
    511       if isCurrent(requestedGeneration) {
    512         await refreshBlossomSnapshot()
    513         guard isCurrent(requestedGeneration) else { return }
    514         message = TeraAddPresentation.message(for: error)
    515         lastFailureCode = TeraAddPresentation.failure(for: error)?.code
    516       }
    517     }
    518   }
    519 }
    520 
    521 /// Lifecycle remains in the same lexical owner so private callback guards and
    522 /// editing authority cannot be bypassed by a second controller.
    523 extension TeraAddStore {
    524   func capturePhoto() async {
    525     await prepareMedia(capture: true)
    526   }
    527 
    528   func recheckMediaAccess() async {
    529     guard !isWorking else { return }
    530     await mediaAccess.recheck()
    531   }
    532 
    533   func configure(snapshot: TeraRuntimeSnapshot) {
    534     let updated = TeraPresentationConfiguration(snapshot: snapshot)
    535     let scope = TeraComposerScope(authorPublicKey: updated.publicKey, localNetworkID: updated.context.id)
    536     let scopeChanged = composer.scope != scope
    537     recovery.configure(scope: scope)
    538     submissions.configure(scope: scope, context: updated.context)
    539     if scopeChanged {
    540       composer.reset(scope: scope)
    541     }
    542     if let configuration, configuration != updated {
    543       stop()
    544       schemas = []
    545       drafts = []
    546       if scopeChanged {
    547         activeDraft = nil
    548         resetRevision()
    549         form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock)
    550       }
    551       state = .idle
    552       mediaAccess.accept(.unavailable)
    553       mediaRecoveryMessage = nil
    554       message = nil
    555       lastFailureCode = nil
    556     }
    557     blossomGeneration = blossomGeneration.invalidated()
    558     configuration = updated
    559     activePublicKey = snapshot.identity.publicKeyHex
    560     blossomConfiguration = snapshot.blossomConfiguration
    561     blossomEvidence = snapshot.blossomEvidence
    562   }
    563 
    564   func start() async {
    565     guard !Task.isCancelled, await !(recovery.transfers.resumeIfObserving(observation.isActive)) else { return }
    566     composer.resume()
    567     submissions.start()
    568     startObservation()
    569     guard operationGeneration == nil else { return }
    570     message = nil
    571     state = .loading
    572     generation = generation.invalidated()
    573     let requestedGeneration = generation
    574     draftsGeneration = draftsGeneration.invalidated()
    575     let draftRequest = appliedDraftsGeneration
    576     let serviceConfiguration = configuration
    577     do {
    578       let loaded = try await TeraAddStartupSnapshot.load(client: runtimeClient, support: mediaAccess.inspect) { schemas in
    579         guard self.isCurrent(requestedGeneration) else { return }
    580         self.schemas = schemas
    581         self.state = .ready
    582         self.recovery.start()
    583       }
    584       guard isCurrent(requestedGeneration) else { return }
    585       if serviceConfiguration == configuration {
    586         mediaRecoveryMessage = loaded.mediaMessage
    587       }
    588       await recovery.transfers.reconcile()
    589       try ensureCurrent(requestedGeneration)
    590       schemas = loaded.schemas
    591       if draftRequest == appliedDraftsGeneration {
    592         appliedDraftsGeneration = try appliedDraftsGeneration.next()
    593         drafts = TeraAddPresentation.sorted(loaded.drafts)
    594       }
    595       if serviceConfiguration == configuration {
    596         mediaAccess.accept(loaded.support)
    597       }
    598       state = .ready
    599       await submissions.refreshSelected()
    600     } catch {
    601       guard isCurrent(requestedGeneration) else { return }
    602       state = .failed(TeraAddPresentation.message(for: error))
    603     }
    604   }
    605 
    606   func stop() {
    607     mediaAccess.invalidate()
    608     submissions.stop()
    609     protection.cancel()
    610     recovery.stop()
    611     composer.stop()
    612     generation = generation.invalidated()
    613     operationGeneration = nil
    614     probeGeneration = probeGeneration.invalidated()
    615     isCheckingBlossom = false
    616     operationTask?.cancel()
    617     operationTask = nil
    618     observation.stop()
    619     observationState = .stopped
    620     isWorking = false
    621   }
    622 
    623   func suspend() {
    624     recovery.stop()
    625     observation.stop()
    626     observationState = .stopped
    627   }
    628 }
    629 
    630 private extension TeraAddStore {
    631   func resetRevision() {
    632     revisionTarget = nil
    633     revisionPreparation.reset()
    634     revisionOperationID = nil
    635   }
    636 
    637   func saveCurrentForm(generation requestedGeneration: TeraSessionGeneration) async throws -> TeraDraftStatus {
    638     if let target = revisionTarget {
    639       let revision = try await revisionPreparation.prepare(target: target, form: form, identifier: identifier,
    640                                                            ensureCurrent: { try self.ensureCurrent(requestedGeneration) })
    641       try ensureCurrent(requestedGeneration)
    642       revisionOperationID = revision.operationID
    643       revisionTarget = nil
    644       revisionPreparation.reset()
    645       try accept(revision, generation: requestedGeneration)
    646       return revision.replacement
    647     }
    648     guard isFormEditable else { throw TeraComposerAcknowledgment.unconfirmed }
    649     let opened = try await TeraOpenedMedia.open(form.media, using: media)
    650     defer { opened.close() }
    651     try ensureCurrent(requestedGeneration)
    652     let input = TeraAddRuntimeInput(form: form, media: opened.handles)
    653     let status = try await runtimeClient.saveAddIntent(
    654       input: input,
    655       existingDraftID: activeDraft?.isRevision == true ? nil : activeDraft?.id,
    656       expectedRevision: activeDraft?.isRevision == true ? nil : activeDraft?.revision
    657     )
    658     try accept(status, generation: requestedGeneration)
    659     return status
    660   }
    661 
    662   var needsEditingPreservation: Bool {
    663     revisionTarget != nil || (isFormEditable && (composer.isDirty || submissions.hasAction))
    664   }
    665 
    666   func preservation() -> () async -> Bool {
    667     let scope = composer.scope
    668     return { [weak self] in
    669       guard let self, composer.scope == scope else { return false }
    670       guard needsEditingPreservation else { return true }
    671       let editing = form
    672       do {
    673         try await submissions.preserveForReplacement(currentForm: { self.form })
    674       } catch {
    675         message = TeraAddPresentation.message(for: error)
    676         return false
    677       }
    678       await save()
    679       return composer.scope == scope && form == editing && revisionTarget == nil && !composer.isDirty
    680     }
    681   }
    682 
    683   func replacement(_ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) -> () async -> Bool {
    684     let scope = composer.scope
    685     return { [weak self] in
    686       guard let self, composer.scope == scope else { return false }
    687       var applied = false
    688       await perform { requested in
    689         try await operation(self, requested)
    690         applied = true
    691       }
    692       return applied
    693     }
    694   }
    695 
    696   @discardableResult
    697   func replaceEditing(kind: TeraEditingProtection.Kind = .editing,
    698                       _ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) async -> Bool
    699   {
    700     await protection.replace(kind: kind, save: preservation(), apply: replacement(operation))
    701   }
    702 }