1use crate::query_mapped_rows;
2use crate::*;
3use coven_protocol::store_commit::StoreBatchCommitRef;
4use rusqlite::{Connection, OptionalExtension};
5
6use super::*;
7
8impl StoreSession<'_> {
9 fn circle_operations(
10 &self,
11 ) -> Result<Vec<coven_protocol::circle::CircleOperationInfo>, DbError> {
12 let conn = self.conn;
13 let rows = crate::query_mapped_rows(
14 conn,
15 "SELECT operation_id, circle_id, prepared, phase
16 FROM circle_operations
17 ORDER BY rowid",
18 [],
19 |row| {
20 Ok((
21 row.get::<_, String>(0)?,
22 row.get::<_, String>(1)?,
23 row.get::<_, Vec<u8>>(2)?,
24 row.get::<_, String>(3)?,
25 ))
26 },
27 )
28 .map_err(DbError::from)?;
29 rows.into_iter()
30 .map(|(operation_id, circle_id, prepared, phase)| {
31 let uploaded = crate::circle_operation_uploaded_steps_on(conn, &operation_id)?;
32 let journal = parse_circle_operation_row(
33 &operation_id,
34 &circle_id,
35 &prepared,
36 &phase,
37 uploaded,
38 )?;
39 Ok(coven_protocol::circle::CircleOperationInfo {
40 operation_id: journal.operation_id.clone(),
41 circle_id: journal.circle_id(),
42 kind: journal.kind(),
43 state: journal.state(),
44 })
45 })
46 .collect()
47 }
48
49 fn circle_states(
50 &self,
51 identity_pubkey: &str,
52 active_store_members: &std::collections::BTreeSet<String>,
53 ) -> Result<Vec<coven_protocol::circle::Circle>, DbError> {
54 Ok(circle_current_states_on(self.conn)?
55 .into_iter()
56 .map(|state| {
57 let (name, role) = state.display(identity_pubkey);
58 coven_protocol::circle::Circle {
59 id: state.circle_id(),
60 name,
61 role,
62 state: state.derived_state(active_store_members),
63 }
64 })
65 .collect())
66 }
67
68 fn circle_members(
69 &self,
70 circle_id: coven_protocol::circle::CircleId,
71 identity_pubkey: &str,
72 store_members: &std::collections::BTreeSet<String>,
73 ) -> Result<Vec<coven_protocol::circle::CircleMemberInfo>, DbError> {
74 let state = circle_current_state_on(self.conn, circle_id)?
75 .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
76 let Some((_current, access, roster, _metadata)) = state.active() else {
77 return Err(DbError::Message(format!(
78 "Circle {circle_id} has no active local state"
79 )));
80 };
81 if access.recipient_pubkey != identity_pubkey {
82 return Err(DbError::Message(format!(
83 "active Circle {circle_id} belongs to another local identity"
84 )));
85 }
86 Ok(roster
87 .members()
88 .into_iter()
89 .filter(|(pubkey, _)| store_members.contains(pubkey))
90 .map(|(pubkey, role)| coven_protocol::circle::CircleMemberInfo {
91 is_self: pubkey == identity_pubkey,
92 pubkey,
93 role,
94 })
95 .collect())
96 }
97
98 fn circle_signing_context(
99 &self,
100 circle_id: coven_protocol::circle::CircleId,
101 identity_pubkey: &str,
102 authoring: fn(
103 &coven_protocol::circle_activation::CircleCurrentState,
104 ) -> Option<coven_protocol::circle_activation::CircleAuthoringState>,
105 missing_authoring: fn(coven_protocol::circle::CircleId) -> String,
106 foreign_identity: fn(coven_protocol::circle::CircleId) -> String,
107 ) -> Result<
108 (
109 coven_protocol::circle_activation::CircleAuthoringState,
110 StoreBatchCommitRef,
111 ),
112 DbError,
113 > {
114 let state = circle_current_state_on(self.conn, circle_id)?
115 .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
116 let authoring =
117 authoring(&state).ok_or_else(|| DbError::Message(missing_authoring(circle_id)))?;
118 if authoring.access.recipient_pubkey != identity_pubkey {
119 return Err(DbError::Message(foreign_identity(circle_id)));
120 }
121 let activated_commit = super::circle_authority::circle_activation_commit_ref_on(
122 self.conn,
123 circle_id,
124 &authoring.control.coord,
125 )?
126 .ok_or_else(|| {
127 DbError::Message(format!(
128 "Circle {circle_id} current control has no materialized activation"
129 ))
130 })?;
131 Ok((authoring, activated_commit))
132 }
133
134 fn circle_control_conflict_branches(
135 &self,
136 circle_id: coven_protocol::circle::CircleId,
137 ) -> Result<Option<Vec<coven_protocol::circle::CircleControlCoord>>, DbError> {
138 Ok(circle_current_state_on(self.conn, circle_id)?
139 .and_then(|state| state.conflict_branches()))
140 }
141
142 fn circle_is_deleted(
143 &self,
144 circle_id: coven_protocol::circle::CircleId,
145 ) -> Result<bool, DbError> {
146 Ok(circle_current_state_on(self.conn, circle_id)?.is_some_and(|state| state.is_deleted()))
147 }
148
149 fn current_circle_control(
150 &self,
151 circle_id: coven_protocol::circle::CircleId,
152 ) -> Result<Option<coven_protocol::circle::CircleControlCoord>, DbError> {
153 Ok(
154 circle_current_state_on(self.conn, circle_id)?.and_then(|state| {
155 state
156 .authoring_state()
157 .map(|authoring| authoring.control.coord.clone())
158 }),
159 )
160 }
161
162 fn closing_circle_controls(
163 &self,
164 ) -> Result<Vec<coven_protocol::circle::PreparedCircleControl>, DbError> {
165 Ok(circle_current_states_on(self.conn)?
166 .into_iter()
167 .filter_map(|state| state.closing_control().cloned())
168 .collect())
169 }
170
171 fn circle_publication_context(
172 &self,
173 circle_id: coven_protocol::circle::CircleId,
174 expected_control: &coven_protocol::circle::CircleControlCoord,
175 ) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
176 circle_publication_context_on(self.conn, circle_id, expected_control)
177 }
178
179 fn current_circle_partition_control(
180 &self,
181 circle_id: coven_protocol::circle::CircleId,
182 ) -> Result<crate::CirclePartitionControl, DbError> {
183 crate::active_circle_control(self.conn, circle_id).map_err(DbError::from)
184 }
185
186 fn circle_publication_rotation_block(
187 &self,
188 circle_id: coven_protocol::circle::CircleId,
189 active_store_members: &std::collections::BTreeSet<String>,
190 ) -> Result<Option<coven_protocol::circle::CirclePublicationBlocked>, DbError> {
191 let Some(state) = circle_current_state_on(self.conn, circle_id)? else {
192 return Ok(None);
193 };
194 Ok(state
195 .rotation_required(active_store_members)
196 .map(
197 |rotation| coven_protocol::circle::CirclePublicationBlocked::RotationRequired {
198 circle_id,
199 removed_members: rotation.removed_members,
200 },
201 ))
202 }
203
204 fn record_circle_close_exclusions(
205 &self,
206 exclusions: &[coven_protocol::circle_activation::LocalCircleExclusion],
207 ) -> Result<(), DbError> {
208 let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
209 for exclusion in exclusions {
210 record_circle_close_exclusion_on(&tx, exclusion)?;
211 }
212 tx.commit().map_err(DbError::from)
213 }
214
215 #[cfg(any(test, feature = "test-utils"))]
216 fn circles(
217 &self,
218 identity_pubkey: &str,
219 active_store_members: &std::collections::BTreeSet<String>,
220 ) -> Result<Vec<coven_protocol::circle::CircleInfo>, DbError> {
221 let mut circles = Vec::new();
222 for state in circle_current_states_on(self.conn)? {
223 let circle_id = state.circle_id();
224 if state.is_deleted() {
225 circles.push(coven_protocol::circle::CircleInfo::Deleted { id: circle_id });
226 } else if let Some(branches) = state.conflict_branches() {
227 circles.push(coven_protocol::circle::CircleInfo::Conflicted {
228 id: circle_id,
229 branches,
230 });
231 } else if let Some((_current, access, roster, metadata)) = state.active() {
232 if access.recipient_pubkey != identity_pubkey {
233 return Err(DbError::Message(format!(
234 "active circle {circle_id} belongs to another local identity"
235 )));
236 }
237 let role = roster
238 .members()
239 .get(identity_pubkey)
240 .copied()
241 .ok_or_else(|| {
242 DbError::Message(format!(
243 "activated circle {circle_id} excludes the local identity"
244 ))
245 })?;
246 circles.push(coven_protocol::circle::CircleInfo::Active {
247 id: circle_id,
248 name: metadata.name.clone(),
249 role,
250 rotation_required: state.rotation_required(active_store_members).is_some(),
251 });
252 }
253 }
254 Ok(circles)
255 }
256}
257
258impl StoreDatabase {
259 pub async fn get_circle_operations(
260 &self,
261 ) -> Result<Vec<coven_protocol::circle::CircleOperationInfo>, DbError> {
262 self.call_store(|session| session.circle_operations()).await
263 }
264
265 pub async fn circle_states(
270 &self,
271 identity_pubkey: &str,
272 active_store_members: std::collections::BTreeSet<String>,
273 ) -> Result<Vec<coven_protocol::circle::Circle>, DbError> {
274 let identity_pubkey = identity_pubkey.to_string();
275 self.call_store(move |session| {
276 session.circle_states(&identity_pubkey, &active_store_members)
277 })
278 .await
279 }
280
281 pub async fn get_circle_members(
282 &self,
283 circle_id: coven_protocol::circle::CircleId,
284 identity_pubkey: &str,
285 store_members: std::collections::BTreeSet<String>,
286 ) -> Result<Vec<coven_protocol::circle::CircleMemberInfo>, DbError> {
287 let identity_pubkey = identity_pubkey.to_string();
288 self.call_store(move |session| {
289 session.circle_members(circle_id, &identity_pubkey, &store_members)
290 })
291 .await
292 }
293
294 pub async fn circle_authoring_context(
295 &self,
296 circle_id: coven_protocol::circle::CircleId,
297 identity_pubkey: &str,
298 ) -> Result<
299 (
300 coven_protocol::circle_activation::CircleAuthoringState,
301 StoreBatchCommitRef,
302 ),
303 DbError,
304 > {
305 self.circle_signing_context(
306 circle_id,
307 identity_pubkey,
308 |state| state.authoring_state(),
309 |circle_id| format!("Circle {circle_id} has no active authoring state"),
310 |circle_id| format!("active Circle {circle_id} belongs to another local identity"),
311 )
312 .await
313 }
314
315 async fn circle_signing_context(
319 &self,
320 circle_id: coven_protocol::circle::CircleId,
321 identity_pubkey: &str,
322 authoring: fn(
323 &coven_protocol::circle_activation::CircleCurrentState,
324 ) -> Option<coven_protocol::circle_activation::CircleAuthoringState>,
325 missing_authoring: fn(coven_protocol::circle::CircleId) -> String,
326 foreign_identity: fn(coven_protocol::circle::CircleId) -> String,
327 ) -> Result<
328 (
329 coven_protocol::circle_activation::CircleAuthoringState,
330 StoreBatchCommitRef,
331 ),
332 DbError,
333 > {
334 let identity_pubkey = identity_pubkey.to_string();
335 self.call_store(move |session| {
336 session.circle_signing_context(
337 circle_id,
338 &identity_pubkey,
339 authoring,
340 missing_authoring,
341 foreign_identity,
342 )
343 })
344 .await
345 }
346
347 pub async fn circle_delete_context(
353 &self,
354 circle_id: coven_protocol::circle::CircleId,
355 identity_pubkey: &str,
356 ) -> Result<
357 (
358 coven_protocol::circle_activation::CircleAuthoringState,
359 StoreBatchCommitRef,
360 ),
361 DbError,
362 > {
363 self.circle_signing_context(
364 circle_id,
365 identity_pubkey,
366 |state| state.deletable_authoring_state(),
367 |circle_id| format!("Circle {circle_id} has no resolved authoring state to delete"),
368 |circle_id| format!("Circle {circle_id} belongs to another local identity"),
369 )
370 .await
371 }
372
373 pub async fn circle_control_conflict_branches(
377 &self,
378 circle_id: coven_protocol::circle::CircleId,
379 ) -> Result<Option<Vec<coven_protocol::circle::CircleControlCoord>>, DbError> {
380 self.call_store(move |session| session.circle_control_conflict_branches(circle_id))
381 .await
382 }
383
384 pub async fn circle_is_deleted(
386 &self,
387 circle_id: coven_protocol::circle::CircleId,
388 ) -> Result<bool, DbError> {
389 self.call_store(move |session| session.circle_is_deleted(circle_id))
390 .await
391 }
392
393 pub async fn current_circle_control(
397 &self,
398 circle_id: coven_protocol::circle::CircleId,
399 ) -> Result<Option<coven_protocol::circle::CircleControlCoord>, DbError> {
400 self.call_store(move |session| session.current_circle_control(circle_id))
401 .await
402 }
403
404 pub async fn closing_circle_controls(
405 &self,
406 ) -> Result<Vec<coven_protocol::circle::PreparedCircleControl>, DbError> {
407 self.call_store(|session| session.closing_circle_controls())
408 .await
409 }
410
411 pub async fn circle_closing_context(
412 &self,
413 circle_id: coven_protocol::circle::CircleId,
414 identity_pubkey: &str,
415 ) -> Result<
416 (
417 coven_protocol::circle_activation::CircleAuthoringState,
418 StoreBatchCommitRef,
419 ),
420 DbError,
421 > {
422 self.circle_signing_context(
423 circle_id,
424 identity_pubkey,
425 |state| state.closing_authoring_state(),
426 |circle_id| format!("Circle {circle_id} is not closing"),
427 |circle_id| format!("closing Circle {circle_id} belongs to another local identity"),
428 )
429 .await
430 }
431
432 pub async fn circle_publication_context(
433 &self,
434 circle_id: coven_protocol::circle::CircleId,
435 expected_control: coven_protocol::circle::CircleControlCoord,
436 ) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
437 self.call_store(move |session| {
438 session.circle_publication_context(circle_id, &expected_control)
439 })
440 .await
441 }
442
443 pub async fn current_circle_partition_control(
449 &self,
450 circle_id: coven_protocol::circle::CircleId,
451 ) -> Result<crate::CirclePartitionControl, DbError> {
452 self.call_store(move |session| session.current_circle_partition_control(circle_id))
453 .await
454 }
455
456 pub async fn circle_publication_rotation_block(
462 &self,
463 circle_id: coven_protocol::circle::CircleId,
464 active_store_members: std::collections::BTreeSet<String>,
465 ) -> Result<Option<coven_protocol::circle::CirclePublicationBlocked>, DbError> {
466 self.call_store(move |session| {
467 session.circle_publication_rotation_block(circle_id, &active_store_members)
468 })
469 .await
470 }
471
472 pub async fn record_circle_close_exclusions(
473 &self,
474 exclusions: Vec<coven_protocol::circle_activation::LocalCircleExclusion>,
475 ) -> Result<(), DbError> {
476 self.call_store(move |session| session.record_circle_close_exclusions(&exclusions))
477 .await
478 }
479
480 #[cfg(any(test, feature = "test-utils"))]
481 pub async fn get_circles(
482 &self,
483 identity_pubkey: &str,
484 active_store_members: std::collections::BTreeSet<String>,
485 ) -> Result<Vec<coven_protocol::circle::CircleInfo>, DbError> {
486 let identity_pubkey = identity_pubkey.to_string();
487 self.call_store(move |session| session.circles(&identity_pubkey, &active_store_members))
488 .await
489 }
490}
491
492pub(crate) fn record_circle_close_exclusion_on(
498 conn: &Connection,
499 exclusion: &coven_protocol::circle_activation::LocalCircleExclusion,
500) -> Result<(), DbError> {
501 let circle_id = exclusion.circle_id.to_string();
502 let close_id = serde_json::to_string(&exclusion.close_id)
503 .map_err(|error| DbError::context("serialize close exclusion id", error))?;
504 let excluded = serde_json::to_string(&exclusion.excluded)
505 .map_err(|error| DbError::context("serialize close exclusion registration", error))?;
506 let successor_control = serde_json::to_string(&exclusion.successor_control)
507 .map_err(|error| DbError::context("serialize close exclusion successor", error))?;
508 let activating_commit = serde_json::to_string(&exclusion.activating_commit)
509 .map_err(|error| DbError::context("serialize close exclusion activation", error))?;
510 conn.execute(
511 "INSERT INTO circle_close_exclusions
512 (circle_id, close_id, excluded_registration, successor_control, activating_commit)
513 VALUES (?1, ?2, ?3, ?4, ?5)
514 ON CONFLICT(circle_id) DO UPDATE SET
515 close_id = excluded.close_id,
516 excluded_registration = excluded.excluded_registration,
517 successor_control = excluded.successor_control,
518 activating_commit = excluded.activating_commit",
519 rusqlite::params![
520 circle_id,
521 close_id,
522 excluded,
523 successor_control,
524 activating_commit,
525 ],
526 )
527 .map_err(DbError::from)?;
528 Ok(())
529}
530
531pub(crate) fn circle_current_states_on(
535 conn: &Connection,
536) -> Result<Vec<coven_protocol::circle_activation::CircleCurrentState>, DbError> {
537 let rows = query_mapped_rows(
538 conn,
539 "SELECT circle_id, state FROM circle_current_state ORDER BY circle_id",
540 [],
541 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)),
542 )?;
543 rows.into_iter()
544 .map(|(stored_circle_id, payload)| parse_circle_current_state(&stored_circle_id, &payload))
545 .collect()
546}
547
548pub(crate) fn circle_current_state_on(
549 conn: &Connection,
550 circle_id: coven_protocol::circle::CircleId,
551) -> Result<Option<coven_protocol::circle_activation::CircleCurrentState>, DbError> {
552 let stored = conn
553 .query_row(
554 "SELECT circle_id, state FROM circle_current_state WHERE circle_id = ?1",
555 [circle_id.to_string()],
556 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)),
557 )
558 .optional()
559 .map_err(DbError::from)?;
560 stored
561 .map(|(stored_circle_id, state)| parse_circle_current_state(&stored_circle_id, &state))
562 .transpose()
563}
564
565fn parse_circle_current_state(
566 stored_circle_id: &str,
567 payload: &[u8],
568) -> Result<coven_protocol::circle_activation::CircleCurrentState, DbError> {
569 let circle_id: coven_protocol::circle::CircleId = stored_circle_id
570 .parse()
571 .map_err(|error| DbError::context("parse current Circle id", error))?;
572 let state: coven_protocol::circle_activation::CircleCurrentState =
573 serde_json::from_slice(payload)
574 .map_err(|error| DbError::context("parse Circle current state", error))?;
575 if !state.verify() || state.circle_id() != circle_id {
576 return Err(DbError::Message(format!(
577 "Circle {circle_id} has invalid current state"
578 )));
579 }
580 Ok(state)
581}
582
583pub(crate) fn remove_local_circle_access_on(conn: &Connection) -> Result<(), DbError> {
584 for state in circle_current_states_on(conn)? {
585 let circle_id = state.circle_id().to_string();
586 let state = state.without_local_access();
587 let payload = serde_json::to_vec(&state)
588 .map_err(|error| DbError::context("serialize public Circle current state", error))?;
589 let changed = conn
590 .execute(
591 "UPDATE circle_current_state
592 SET state = ?2
593 WHERE circle_id = ?1",
594 rusqlite::params![circle_id, payload],
595 )
596 .map_err(DbError::from)?;
597 if changed != 1 {
598 return Err(DbError::Message(
599 "Circle current state changed while removing local access".to_string(),
600 ));
601 }
602 }
603 conn.execute_batch("DELETE FROM circle_access_cache;")
604 .map_err(DbError::from)?;
605 Ok(())
606}
607
608pub(crate) fn circle_publication_context_on(
609 conn: &Connection,
610 circle_id: coven_protocol::circle::CircleId,
611 expected_control: &coven_protocol::circle::CircleControlCoord,
612) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
613 let exclusion: Option<(String, String)> = conn
617 .query_row(
618 "SELECT close_id, activating_commit FROM circle_close_exclusions
619 WHERE circle_id = ?1",
620 [circle_id.to_string()],
621 |row| Ok((row.get(0)?, row.get(1)?)),
622 )
623 .optional()
624 .map_err(DbError::from)?;
625 if let Some((close_id, activating_commit)) = exclusion {
626 let coverage_commit: Option<String> = conn
627 .query_row(
628 "SELECT activation_commit FROM circle_bootstrap_coverage WHERE circle_id = ?1",
629 [circle_id.to_string()],
630 |row| row.get(0),
631 )
632 .optional()
633 .map_err(DbError::from)?;
634 if coverage_commit.as_deref() != Some(activating_commit.as_str()) {
635 let close_id = serde_json::from_str(&close_id).map_err(|error| {
636 DbError::context("parse pending Circle close exclusion id", error)
637 })?;
638 return Err(DbError::ExcludedDeviceMustReset {
639 circle_id,
640 close_id,
641 });
642 }
643 }
644 let state = circle_current_state_on(conn, circle_id)?
645 .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
646 if state.is_deleted() {
647 return Err(DbError::Message(format!("Circle {circle_id} is deleted")));
648 }
649 let access = state
650 .epoch_access(expected_control)
651 .map_err(DbError::from)?
652 .ok_or_else(|| {
653 DbError::Message(format!("Circle {circle_id} has no active publication key"))
654 })?;
655 Ok(access)
656}
657
658#[cfg(test)]
659#[path = "circle_operations_test.rs"]
660mod tests;