Skip to main content

coven_database/store/store_session/
circle_controls.rs

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