Skip to main content

coven_database/store/store_session/
circle_controls.rs

1use rusqlite::Connection;
2
3use super::{
4    MergeMaterializationTransaction, StoreDatabase, StoreSession, StoreTransactionOutcome,
5    VerifiedStoreTransaction,
6};
7use crate::{
8    candidate_graph_exact_objects, circle_operation_ids_in_phase_on, load_circle_operation_on,
9    load_remote_object_on, persist_prepared_remote_object_on, update_remote_object_on, DbError,
10    PreparedCircleOperationRow, VerifiedMergeMaterialization,
11};
12use coven_protocol::circle::{CircleOperationId, CircleOperationState};
13use coven_protocol::circle_activation::VerifiedCircleActivations;
14use coven_protocol::circle_journal::{CircleOperationJournal, CircleOperationProgress};
15use coven_protocol::objects::PreparedExactObject;
16use coven_protocol::remote_object::remote_object_id;
17use coven_protocol::store_commit::{
18    commit_semantic_prefix, StoreBatchCommit, StoreDeviceHead, VerifiedStoreBatchCommit,
19    VerifiedStoreDeviceOperations,
20};
21
22/// The stored bytes of one operation's objects, supplied alongside the
23/// operation that names them so the database can install the bytes and their
24/// owner claims at the same durable boundary.
25pub type PreparedCircleObjects = std::collections::BTreeMap<String, PreparedExactObject>;
26
27fn persist_circle_operation_objects_on(
28    conn: &Connection,
29    store_dir: &coven_foundation::store_dir::StoreDir,
30    remotes: &[coven_protocol::remote_object::ClosedRemoteObject],
31    prepared_objects: &PreparedCircleObjects,
32    owner: &coven_protocol::store_commit::StoreBatchCommitRef,
33    domain: &str,
34) -> Result<(), DbError> {
35    let mut installed = std::collections::BTreeSet::new();
36    for remote in remotes {
37        persist_prepared_remote_object_on(conn, store_dir, remote, owner, domain)?;
38        installed.extend(remote.payload_bytes().keys().copied());
39    }
40    for object in prepared_objects.values() {
41        let expected = object.reference().stored_hash();
42        if !installed.insert(expected) {
43            continue;
44        }
45        let actual =
46            crate::payload_store::write_payload_blocking(conn, store_dir, object.stored_bytes())
47                .map_err(|error| DbError::context(format!("install {domain} payload"), error))?;
48        if actual != expected {
49            return Err(DbError::Message(format!(
50                "{domain} payload installed as {actual}, referenced as {expected}"
51            )));
52        }
53    }
54    Ok(())
55}
56
57impl StoreSession<'_> {
58    fn insert_circle_operation(
59        &mut self,
60        journal: CircleOperationJournal,
61        prepared_objects: PreparedCircleObjects,
62    ) -> Result<(), DbError> {
63        let remotes = journal
64            .closed_remote_objects(&prepared_objects)
65            .map_err(DbError::from)?;
66        let owner = journal.operation().commit_ref.clone();
67        let row = PreparedCircleOperationRow::from_journal(&journal)?;
68        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
69        persist_circle_operation_objects_on(
70            &tx,
71            self.store_dir,
72            &remotes,
73            &prepared_objects,
74            &owner,
75            "Circle candidate graph",
76        )?;
77        claim_operation_payloads_on(&tx, &journal.operation_id, journal.operation())?;
78        insert_circle_operation_row_on(&tx, &row)?;
79        tx.commit().map_err(DbError::from)
80    }
81
82    fn insert_circle_operation_superseding(
83        &mut self,
84        journal: CircleOperationJournal,
85        superseded: CircleOperationId,
86        prepared_objects: PreparedCircleObjects,
87    ) -> Result<(), DbError> {
88        let remotes = journal
89            .closed_remote_objects(&prepared_objects)
90            .map_err(DbError::from)?;
91        let owner = journal.operation().commit_ref.clone();
92        let row = PreparedCircleOperationRow::from_journal(&journal)?;
93        let superseded = superseded.as_str().to_string();
94        let circle_id = row.circle_id.clone();
95        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
96        let discarded = load_circle_operation_on(&tx, &superseded)?.ok_or_else(|| {
97            DbError::Message("superseded Circle operation is absent from its slot".to_string())
98        })?;
99        if discarded.circle_id.to_string() != circle_id {
100            return Err(DbError::Message(
101                "superseded Circle operation belongs to another circle".to_string(),
102            ));
103        }
104        release_operation_payloads_on(&tx, &discarded.operation_id)?;
105        let removed = tx
106            .execute(
107                "DELETE FROM circle_operations WHERE operation_id = ?1 AND circle_id = ?2",
108                rusqlite::params![superseded, circle_id],
109            )
110            .map_err(DbError::from)?;
111        if removed != 1 {
112            return Err(DbError::Message(
113                "superseded Circle operation is absent from its slot".to_string(),
114            ));
115        }
116        persist_circle_operation_objects_on(
117            &tx,
118            self.store_dir,
119            &remotes,
120            &prepared_objects,
121            &owner,
122            "Circle candidate graph",
123        )?;
124        claim_operation_payloads_on(&tx, &journal.operation_id, journal.operation())?;
125        insert_circle_operation_row_on(&tx, &row)?;
126        tx.commit().map_err(DbError::from)
127    }
128
129    fn circle_operation(
130        &mut self,
131        operation_id: String,
132    ) -> Result<Option<CircleOperationJournal>, DbError> {
133        load_circle_operation_on(self.conn, &operation_id)
134    }
135
136    fn circle_operation_step(
137        &mut self,
138        operation_id: String,
139        step: String,
140    ) -> Result<PreparedExactObject, DbError> {
141        let journal = load_circle_operation_on(self.conn, &operation_id)?.ok_or_else(|| {
142            DbError::Message(format!(
143                "Circle operation {operation_id} disappeared before opening step {step:?}"
144            ))
145        })?;
146        let object = journal
147            .operation()
148            .prepared_objects
149            .get(&step)
150            .ok_or_else(|| {
151                DbError::Message(format!(
152                    "Circle operation {operation_id} has no payload for step {step:?}"
153                ))
154            })?;
155        let stored_bytes =
156            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
157                .payload(object.stored_hash())
158                .map_err(DbError::from)?;
159        PreparedExactObject::new(object.clone(), stored_bytes)
160            .map_err(|error| DbError::context(format!("Circle operation step {step:?}"), error))
161    }
162
163    fn oldest_pending_circle_operation(
164        &mut self,
165    ) -> Result<Option<CircleOperationJournal>, DbError> {
166        let conn = self.conn;
167        let Some(operation_id) = circle_operation_ids_in_phase_on(conn, |progress| {
168            matches!(
169                progress,
170                CircleOperationProgress::Ready | CircleOperationProgress::Finalizing
171            )
172        })?
173        .into_iter()
174        .next() else {
175            return Ok(None);
176        };
177        load_circle_operation_on(conn, &operation_id)
178    }
179
180    fn waiting_circle_operations(&mut self) -> Result<Vec<CircleOperationJournal>, DbError> {
181        let conn = self.conn;
182        let waiting = circle_operation_ids_in_phase_on(conn, |progress| {
183            matches!(progress, CircleOperationProgress::WaitingForCloseResponses)
184        })?;
185        waiting
186            .iter()
187            .map(|operation_id| {
188                load_circle_operation_on(conn, operation_id)?.ok_or_else(|| {
189                    DbError::Message(format!(
190                        "Circle operation {operation_id} disappeared while being listed"
191                    ))
192                })
193            })
194            .collect()
195    }
196
197    fn complete_circle_operation_upload_step(
198        &mut self,
199        operation_id: String,
200        step: String,
201    ) -> Result<(), DbError> {
202        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
203        let journal = load_circle_operation_on(&tx, &operation_id)?.ok_or_else(|| {
204            DbError::Message(format!(
205                "Circle operation {operation_id} disappeared before its upload step"
206            ))
207        })?;
208        let object = journal
209            .operation()
210            .prepared_objects
211            .get(&step)
212            .ok_or_else(|| {
213                DbError::Message(format!(
214                    "Circle upload step {step:?} names no object of operation {operation_id}"
215                ))
216            })?
217            .clone();
218        let candidate_owned = journal.candidate_owned_objects().map_err(DbError::from)?;
219        tx.execute(
220            "INSERT OR IGNORE INTO circle_operation_uploads (operation_id, step)
221             VALUES (?1, ?2)",
222            rusqlite::params![operation_id, step],
223        )
224        .map_err(DbError::from)?;
225        if candidate_owned.contains(&object) {
226            mark_uploaded_object_on(&tx, remote_object_id(&object))?;
227        }
228        tx.commit().map_err(DbError::from)
229    }
230
231    fn begin_circle_operation_finalization(
232        &mut self,
233        journal: CircleOperationJournal,
234        prepared_objects: PreparedCircleObjects,
235    ) -> Result<(), DbError> {
236        if !matches!(journal.state(), CircleOperationState::Finalizing) {
237            return Err(DbError::Message(
238                "Circle finalization journal is not in finalizing state".to_string(),
239            ));
240        }
241        let remotes = journal
242            .closed_remote_objects(&prepared_objects)
243            .map_err(DbError::from)?;
244        let owner = journal.operation().commit_ref.clone();
245        let row = PreparedCircleOperationRow::from_journal(&journal)?;
246        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
247        let durable =
248            load_circle_operation_on(&tx, journal.operation_id.as_str())?.ok_or_else(|| {
249                DbError::Message(format!(
250                    "Circle operation {} disappeared before finalization",
251                    journal.operation_id
252                ))
253            })?;
254        if !matches!(
255            durable.state(),
256            CircleOperationState::WaitingForCloseResponses
257        ) || durable.circle_id != journal.circle_id
258            || durable.intent != journal.intent
259        {
260            return Err(DbError::Message(format!(
261                "Circle operation {} changed before finalization",
262                journal.operation_id
263            )));
264        }
265        persist_circle_operation_objects_on(
266            &tx,
267            self.store_dir,
268            &remotes,
269            &prepared_objects,
270            &owner,
271            "Circle close-finalization candidate graph",
272        )?;
273        claim_operation_payloads_on(&tx, &journal.operation_id, journal.operation())?;
274        tx.execute(
275            "DELETE FROM circle_operation_uploads WHERE operation_id = ?1",
276            [&row.operation_id],
277        )
278        .map_err(DbError::from)?;
279        let updated = tx
280            .execute(
281                "UPDATE circle_operations SET prepared = ?3, phase = ?4
282                 WHERE operation_id = ?1 AND circle_id = ?2",
283                rusqlite::params![row.operation_id, row.circle_id, row.prepared, row.phase],
284            )
285            .map_err(DbError::from)?;
286        if updated != 1 {
287            return Err(DbError::Message(
288                "Circle operation disappeared during finalization".to_string(),
289            ));
290        }
291        tx.commit().map_err(DbError::from)
292    }
293
294    #[cfg(any(test, feature = "test-utils"))]
295    fn substitute_circle_operation_for_test(
296        &mut self,
297        journal: CircleOperationJournal,
298    ) -> Result<(), DbError> {
299        let row = PreparedCircleOperationRow::from_journal(&journal)?;
300        let updated = self
301            .conn
302            .execute(
303                "UPDATE circle_operations SET prepared = ?3
304                 WHERE operation_id = ?1 AND circle_id = ?2",
305                rusqlite::params![row.operation_id, row.circle_id, row.prepared],
306            )
307            .map_err(DbError::from)?;
308        if updated != 1 {
309            return Err(DbError::Message(format!(
310                "Circle operation {} is absent from its slot",
311                row.operation_id
312            )));
313        }
314        Ok(())
315    }
316
317    fn block_circle_operation(
318        &mut self,
319        operation_id: String,
320        block: coven_protocol::circle::CircleOperationBlock,
321    ) -> Result<(), DbError> {
322        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
323        let mut journal = load_circle_operation_on(&tx, &operation_id)?.ok_or_else(|| {
324            DbError::Message(format!("circle operation {operation_id} is absent"))
325        })?;
326        journal.block(block).map_err(DbError::from)?;
327        update_circle_operation_phase_on(&tx, &journal)?;
328        tx.commit().map_err(DbError::from)
329    }
330
331    fn unblock_circle_operation(&mut self, operation_id: String) -> Result<(), DbError> {
332        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
333        let mut journal = load_circle_operation_on(&tx, &operation_id)?.ok_or_else(|| {
334            DbError::Message(format!("circle operation {operation_id} is absent"))
335        })?;
336        journal.unblock().map_err(DbError::from)?;
337        update_circle_operation_phase_on(&tx, &journal)?;
338        tx.commit().map_err(DbError::from)
339    }
340}
341
342impl VerifiedStoreTransaction<'_, '_, '_> {
343    fn activate_circle_operation(
344        &mut self,
345        journal: CircleOperationJournal,
346        verified: VerifiedCircleActivations,
347    ) -> Result<(), DbError> {
348        let authority = &mut *self.authority;
349        let gates = self.gates;
350        let tx = self.store.transaction;
351        journal.validate_identity().map_err(DbError::from)?;
352        let durable =
353            load_circle_operation_on(tx, journal.operation_id.as_str())?.ok_or_else(|| {
354                DbError::Message(format!(
355                    "circle operation {} disappeared during activation",
356                    journal.operation_id
357                ))
358            })?;
359        if durable != journal {
360            return Err(DbError::Message(format!(
361                "circle operation {} changed before activation",
362                journal.operation_id
363            )));
364        }
365        if !journal.is_publishable() {
366            return Err(DbError::Message(
367                "blocked circle operation cannot activate".to_string(),
368            ));
369        }
370        let operation = journal.operation();
371        let creation = &operation.creation;
372        let resolved_roster = creation.resolved_roster();
373        if !creation.control.verify() || !creation.metadata.verify() || !resolved_roster.verify() {
374            return Err(DbError::Message(
375                "circle operation contains invalid signed objects".to_string(),
376            ));
377        }
378        let unverified_commit: StoreBatchCommit =
379            serde_json::from_slice(&operation.commit_bytes)
380                .map_err(|error| DbError::context("parse circle Store commit", error))?;
381        let root = authority.root().clone();
382        let author =
383            super::verified_store_authority::VerifiedRegistrationLookup::activated_registration_on(
384                authority,
385                crate::store::store_session::StoreRecords::new(
386                    self.store.transaction,
387                    self.store.store_dir,
388                ),
389                &root,
390                &unverified_commit.author_registration,
391            )?;
392        let [activation] = verified.circles() else {
393            return Err(DbError::Message(
394                "local Circle publication must carry one common-verifier result".to_string(),
395            ));
396        };
397        let verify_commit = || {
398            let commit = VerifiedStoreBatchCommit::parse(
399                &operation.commit_bytes,
400                root.store_root_hash,
401                &operation.commit_ref,
402                &author,
403            )
404            .map_err(|error| DbError::context("verify circle Store commit", error))?;
405            if operation.commit_ref.object.slot().logical_key()
406                != commit_semantic_prefix(
407                    commit.candidate_family(),
408                    &operation.commit_ref.coord.stream_id.to_string(),
409                    commit.seq(),
410                    commit.commit_hash(),
411                ) + ".json"
412            {
413                return Err(DbError::Message(
414                    "circle commit exact object occupies a different semantic slot".to_string(),
415                ));
416            }
417            let [control_ref] = commit.circle_controls() else {
418                return Err(DbError::Message(
419                    "circle creation Store commit is not an exact control-only batch".to_string(),
420                ));
421            };
422            let expected_ref = creation.control_ref(
423                control_ref.objects().clone(),
424                Some(control_ref.head_object().clone()),
425            );
426            if control_ref != &expected_ref
427                    || !commit.operations().is_some_and(
428                        coven_protocol::store_commit::StoreCommitOperations::is_circle_control_activation_only,
429                    )
430                {
431                    return Err(DbError::Message(
432                        "circle creation Store commit is not an exact control-only batch"
433                            .to_string(),
434                    ));
435                }
436            if activation.reference != *control_ref
437                || activation.circle_id != creation.circle_id
438                || activation.control != creation.control
439                || verified.stream_activations().activating_commit() != &operation.commit_ref
440                || verified.stream_activations().as_slice() != commit.stream_activations()
441            {
442                return Err(DbError::Message(
443                    "common-verifier Circle result differs from the durable signed operation"
444                        .to_string(),
445                ));
446            }
447            Ok(commit)
448        };
449        let (commit, activation, head_object_id, retained) = {
450            let head = &operation.policy.head;
451            let history_evidence = &operation.policy.history_evidence;
452            let commit = verify_commit()?;
453            let parsed = StoreDeviceHead::parse_at(
454                &head.to_bytes(),
455                commit.store_root_hash,
456                &author,
457                &operation.commit_ref,
458            )
459            .map_err(|error| DbError::context("verify circle activation head", error))?;
460            if parsed.commit != operation.commit_ref {
461                return Err(DbError::Message(
462                    "circle activation head names a different commit".to_string(),
463                ));
464            }
465            let device_operations = VerifiedStoreDeviceOperations::without_exclusions(&commit)
466                .map_err(DbError::from)?;
467            let prepared_head = operation
468                .prepared_objects
469                .get("store-head")
470                .ok_or_else(|| {
471                    DbError::Message(
472                        "Merge Circle operation lacks its prepared Store head".to_string(),
473                    )
474                })?;
475            let materialization = VerifiedMergeMaterialization::verify(
476                &root,
477                &commit,
478                &[],
479                &device_operations,
480                &verified,
481                head,
482                prepared_head,
483                history_evidence,
484                None,
485                &[],
486                None,
487            )?;
488            let retained = MergeMaterializationTransaction::from_store(self.store)
489                .record_verified_merge_materialization(authority, materialization)?;
490            (
491                commit,
492                activation.clone(),
493                Some(remote_object_id(prepared_head)),
494                retained,
495            )
496        };
497        authority.insert_verified(retained)?;
498        let mut object_ids = candidate_graph_exact_objects(&commit)?
499            .iter()
500            .map(remote_object_id)
501            .collect::<Vec<_>>();
502        for access in &creation.access {
503            if let coven_protocol::circle::CircleAccessDisposition::Active {
504                bootstrap: Some(bootstrap),
505                ..
506            } = &access.leaf.value.disposition
507            {
508                object_ids.extend(bootstrap.blobs.iter().map(|blob| {
509                    remote_object_id(
510                        blob.stored()
511                            .expect("verified bootstrap remote blob has a locator")
512                            .object(),
513                    )
514                }));
515            }
516        }
517        object_ids.push(remote_object_id(&operation.commit_ref.object));
518        if let Some(head_object_id) = head_object_id {
519            object_ids.push(head_object_id);
520        }
521        let store_transaction = MergeMaterializationTransaction::from_store(self.store);
522        store_transaction
523            .activate_store_operation_remote_objects(&operation.commit_ref, &object_ids)?;
524        store_transaction.record_verified_circle_activations(&commit, &[activation])?;
525        // A deletion the local device authored prunes its own rows,
526        // routes, and blob bindings in this activation transaction.
527        // Recording the verified activation above already removed its
528        // live access cache while retaining the control activation spine.
529        if store_transaction.circle_current_state_is_deleted(creation.circle_id)? {
530            crate::prune_ineligible_scoped_rows(
531                tx,
532                gates,
533                &std::collections::BTreeSet::from([creation.circle_id]),
534            )
535            .map_err(DbError::from)?;
536        }
537        if !journal.is_finalizing()
538            && matches!(
539                journal.intent,
540                coven_protocol::circle_journal::CircleOperationIntent::RemoveMember { .. }
541            )
542        {
543            let mut waiting = journal;
544            waiting.wait_for_close_responses().map_err(DbError::from)?;
545            update_circle_operation_phase_on(tx, &waiting)?;
546        } else {
547            release_operation_payloads_on(tx, &journal.operation_id)?;
548            let deleted = tx
549                .execute(
550                    "DELETE FROM circle_operations WHERE operation_id = ?1 AND circle_id = ?2",
551                    rusqlite::params![
552                        journal.operation_id.as_str(),
553                        creation.circle_id.to_string()
554                    ],
555                )
556                .map_err(DbError::from)?;
557            if deleted != 1 {
558                return Err(DbError::Message(
559                    "circle operation disappeared during activation".to_string(),
560                ));
561            }
562        }
563        Ok(())
564    }
565}
566
567impl StoreSession<'_> {
568    fn activate_circle_operation(
569        &mut self,
570        journal: CircleOperationJournal,
571        verified: VerifiedCircleActivations,
572    ) -> Result<(), DbError> {
573        self.verified_store_transaction(move |transaction| {
574            transaction.activate_circle_operation(journal, verified)?;
575            Ok(StoreTransactionOutcome::Commit(()))
576        })
577    }
578}
579
580impl StoreDatabase {
581    pub async fn insert_circle_operation(
582        &self,
583        journal: CircleOperationJournal,
584        prepared_objects: PreparedCircleObjects,
585    ) -> Result<(), DbError> {
586        self.call_store(move |session| session.insert_circle_operation(journal, prepared_objects))
587            .await
588    }
589
590    /// Insert the terminal deletion operation, superseding the operation that
591    /// currently holds the Circle's single operation slot. A closing Circle keeps
592    /// a waiting close operation there; the deletion removes it and takes the slot
593    /// in one transaction, so no window leaves the Circle carrying both a pending
594    /// close and a pending deletion.
595    pub async fn insert_circle_operation_superseding(
596        &self,
597        journal: CircleOperationJournal,
598        superseded: CircleOperationId,
599        prepared_objects: PreparedCircleObjects,
600    ) -> Result<(), DbError> {
601        self.call_store(move |session| {
602            session.insert_circle_operation_superseding(journal, superseded, prepared_objects)
603        })
604        .await
605    }
606
607    pub async fn circle_operation(
608        &self,
609        operation_id: &CircleOperationId,
610    ) -> Result<Option<CircleOperationJournal>, DbError> {
611        let operation_id = operation_id.as_str().to_string();
612        self.call_store(move |session| session.circle_operation(operation_id))
613            .await
614    }
615
616    pub async fn circle_operation_step(
617        &self,
618        operation_id: &CircleOperationId,
619        step: &str,
620    ) -> Result<PreparedExactObject, DbError> {
621        let operation_id = operation_id.as_str().to_string();
622        let step = step.to_string();
623        self.call_store(move |session| session.circle_operation_step(operation_id, step))
624            .await
625    }
626
627    pub async fn oldest_pending_circle_operation(
628        &self,
629    ) -> Result<Option<CircleOperationJournal>, DbError> {
630        self.call_store(|session| session.oldest_pending_circle_operation())
631            .await
632    }
633
634    pub async fn waiting_circle_operations(&self) -> Result<Vec<CircleOperationJournal>, DbError> {
635        self.call_store(|session| session.waiting_circle_operations())
636            .await
637    }
638
639    /// Record that one upload step finished: the step's row, and — for a step
640    /// carrying an object the candidate owns — that object's uploaded state.
641    ///
642    /// A step whose object is a shared Circle object rather than a
643    /// candidate-exclusive one records only its row: no `remote_objects` record
644    /// exists for it to mark, which is why the operation's own commit decides
645    /// that rather than an absent lookup.
646    ///
647    /// The operation beside it is untouched. Both writes are idempotent, so a
648    /// retry of a step whose transaction already committed is a no-op rather
649    /// than a conflict, and the foreign key is what refuses a step for an
650    /// operation that is no longer there.
651    pub async fn complete_circle_operation_upload_step(
652        &self,
653        operation_id: &CircleOperationId,
654        step: &str,
655    ) -> Result<(), DbError> {
656        let operation_id = operation_id.as_str().to_string();
657        let step = step.to_string();
658        self.call_store(move |session| {
659            session.complete_circle_operation_upload_step(operation_id, step)
660        })
661        .await
662    }
663
664    /// Replace a closed operation with its freshly prepared finalization.
665    ///
666    /// This is the one transition that rewrites the prepared operation, so it
667    /// is also the one that has to retire what it replaces: the superseded
668    /// operation's upload rows go, because the finalization reuses their step
669    /// names for different objects, and its spool files go, because nothing
670    /// names them once the operation that did is gone.
671    pub async fn begin_circle_operation_finalization(
672        &self,
673        journal: CircleOperationJournal,
674        prepared_objects: PreparedCircleObjects,
675    ) -> Result<(), DbError> {
676        self.call_store(move |session| {
677            session.begin_circle_operation_finalization(journal, prepared_objects)
678        })
679        .await
680    }
681
682    /// Replace one operation's prepared payload with a substituted one, leaving
683    /// its phase and upload rows where they are.
684    ///
685    /// Production rewrites `prepared` only at the close-to-finalization
686    /// boundary. This is how a test hands the publication and activation paths
687    /// a durable operation that contradicts what it names, to check that they
688    /// refuse it rather than trusting the row.
689    #[cfg(any(test, feature = "test-utils"))]
690    pub async fn substitute_circle_operation_for_test(
691        &self,
692        journal: CircleOperationJournal,
693    ) -> Result<(), DbError> {
694        self.call_store(move |session| session.substitute_circle_operation_for_test(journal))
695            .await
696    }
697
698    pub async fn block_circle_operation(
699        &self,
700        operation_id: &CircleOperationId,
701        block: coven_protocol::circle::CircleOperationBlock,
702    ) -> Result<(), DbError> {
703        let operation_id = operation_id.as_str().to_string();
704        self.call_store(move |session| session.block_circle_operation(operation_id, block))
705            .await
706    }
707
708    pub async fn unblock_circle_operation(
709        &self,
710        operation_id: &CircleOperationId,
711    ) -> Result<(), DbError> {
712        let operation_id = operation_id.as_str().to_string();
713        self.call_store(move |session| session.unblock_circle_operation(operation_id))
714            .await
715    }
716
717    pub async fn activate_circle_operation(
718        &self,
719        journal: CircleOperationJournal,
720        verified: VerifiedCircleActivations,
721    ) -> Result<(), DbError> {
722        self.call_store(move |session| session.activate_circle_operation(journal, verified))
723            .await
724    }
725}
726
727fn insert_circle_operation_row_on(
728    conn: &Connection,
729    row: &PreparedCircleOperationRow,
730) -> Result<(), DbError> {
731    conn.execute(
732        "INSERT INTO circle_operations (operation_id, circle_id, prepared, phase)
733         VALUES (?1, ?2, ?3, ?4)",
734        rusqlite::params![row.operation_id, row.circle_id, row.prepared, row.phase],
735    )
736    .map_err(DbError::from)
737    .map(|_| ())
738}
739
740/// Move one operation to the phase it now stands in, leaving the prepared
741/// operation and its completed upload steps as they are.
742pub(crate) fn update_circle_operation_phase_on(
743    conn: &Connection,
744    journal: &CircleOperationJournal,
745) -> Result<(), DbError> {
746    let updated = conn
747        .execute(
748            "UPDATE circle_operations SET phase = ?3
749             WHERE operation_id = ?1 AND circle_id = ?2",
750            rusqlite::params![
751                journal.operation_id.as_str(),
752                journal.circle_id.to_string(),
753                crate::circle_operation_phase_json(&journal.progress)?
754            ],
755        )
756        .map_err(DbError::from)?;
757    if updated != 1 {
758        return Err(DbError::Message(format!(
759            "circle operation {} disappeared during publication",
760            journal.operation_id
761        )));
762    }
763    Ok(())
764}
765
766/// Claim the spool file behind every object this operation names.
767///
768/// Called in the transaction that writes the operation row, so the row and its
769/// claims commit together. An object the operation shares with a surviving
770/// `remote_objects` record is claimed twice over, which is what keeps the file
771/// alive when the operation lets go of it.
772///
773/// The whole claim set is replaced rather than added to, so the finalization
774/// boundary — which hands one operation id a new object graph — never puts an
775/// object carried across it through a moment of being owed a deletion.
776pub(crate) fn claim_operation_payloads_on(
777    conn: &Connection,
778    operation_id: &CircleOperationId,
779    operation: &coven_protocol::circle_journal::PreparedCircleOperation,
780) -> Result<(), DbError> {
781    crate::payload_store::set_payload_owner_claims_on(
782        conn,
783        &crate::payload_store::circle_operation_owner_key(operation_id.as_str()),
784        &operation
785            .prepared_objects
786            .values()
787            .map(coven_protocol::objects::ExactObjectRef::stored_hash)
788            .collect(),
789    )
790}
791
792/// Let go of every spool file this operation claimed.
793///
794/// Called in the transaction that stops the operation naming them, so a file no
795/// row names any more is owed its deletion by the same commit.
796pub(crate) fn release_operation_payloads_on(
797    conn: &Connection,
798    operation_id: &CircleOperationId,
799) -> Result<(), DbError> {
800    crate::payload_store::release_payload_owner_on(
801        conn,
802        &crate::payload_store::circle_operation_owner_key(operation_id.as_str()),
803    )
804}
805
806/// Record that the object one upload step carried is now in cloud storage.
807///
808/// The durable row is the truth being advanced, so it is read and transitioned
809/// in place rather than compared against a reconstruction of what the operation
810/// says it should be.
811fn mark_uploaded_object_on(
812    conn: &Connection,
813    object_id: coven_protocol::store_commit::ObjectHash,
814) -> Result<(), DbError> {
815    let current = load_remote_object_on(conn, object_id)?;
816    let mut uploaded = current.clone();
817    uploaded
818        .mark_uploaded_verified()
819        .map_err(|error| DbError::context(format!("mark {object_id} uploaded"), error))?;
820    if uploaded == current {
821        return Ok(());
822    }
823    update_remote_object_on(conn, object_id, &uploaded)
824}