1use super::{StoreDatabase, StoreSession};
2use crate::{ActiveStorePublication, ActiveStorePublicationOwner, DbError};
3use coven_protocol::store_commit::{StorePublicationPayload, VerifiedStoreBatchCommit};
4use rusqlite::OptionalExtension;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) enum ActiveStorePublicationClaim {
8 Acquired,
9 AlreadyOwned,
10 Occupied(ActiveStorePublicationOwner),
11}
12
13fn encode(value: &ActiveStorePublication) -> Result<String, DbError> {
14 serde_json::to_string(value)
15 .map_err(|error| DbError::context("serialize active Store publication", error))
16}
17
18fn decode(raw: &str) -> Result<ActiveStorePublication, DbError> {
19 serde_json::from_str(raw)
20 .map_err(|error| DbError::context("parse active Store publication", error))
21}
22
23pub(crate) fn load_active_store_publication_on(
24 conn: &rusqlite::Connection,
25) -> Result<Option<ActiveStorePublication>, DbError> {
26 conn.query_row(
27 "SELECT state FROM active_store_publication WHERE singleton = 1",
28 [],
29 |row| row.get::<_, String>(0),
30 )
31 .optional()
32 .map_err(DbError::from)?
33 .map(|raw| decode(&raw))
34 .transpose()
35}
36
37pub(crate) fn claim_active_store_publication_on(
38 conn: &rusqlite::Connection,
39 publication: &ActiveStorePublication,
40) -> Result<ActiveStorePublicationClaim, DbError> {
41 let (device_id, registration_hash, prepared): (String, String, String) = conn
46 .query_row(
47 "SELECT device_id, registration_hash, prepared_object \
48 FROM local_store_device_registration WHERE singleton = 1",
49 [],
50 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
51 )
52 .optional()?
53 .ok_or_else(|| {
54 DbError::Message("Store publication requires a local device registration".into())
55 })?;
56 let prepared: coven_protocol::objects::PreparedExactObject = serde_json::from_str(&prepared)
57 .map_err(|error| DbError::context("local Store registration object", error))?;
58 let author = publication.author_registration();
59 if device_id != author.device_id.to_string()
60 || registration_hash != author.registration_hash.to_string()
61 || prepared.reference() != &author.object
62 {
63 return Err(DbError::Message(
64 "Store publication author differs from the current local registration".into(),
65 ));
66 }
67 match load_active_store_publication_on(conn)? {
68 Some(active) if active == *publication => Ok(ActiveStorePublicationClaim::AlreadyOwned),
69 Some(active) => Ok(ActiveStorePublicationClaim::Occupied(
70 active.owner().clone(),
71 )),
72 None => {
73 conn.execute(
74 "INSERT INTO active_store_publication (singleton, state) VALUES (1, ?1)",
75 [encode(publication)?],
76 )
77 .map_err(DbError::from)?;
78 Ok(ActiveStorePublicationClaim::Acquired)
79 }
80 }
81}
82
83pub(crate) fn clear_active_store_publication_on(
84 conn: &rusqlite::Connection,
85 expected: &ActiveStorePublication,
86) -> Result<(), DbError> {
87 if expected.superseded_entry().is_some()
88 || !expected.retired_candidates().is_empty()
89 || !expected.retired_snapshot_objects().is_empty()
90 {
91 return Err(DbError::Message(
92 "Store publication still owns superseded entry cleanup".to_string(),
93 ));
94 }
95 let deleted = conn
96 .execute(
97 "DELETE FROM active_store_publication WHERE singleton = 1 AND state = ?1",
98 [encode(expected)?],
99 )
100 .map_err(DbError::from)?;
101 if deleted != 1 {
102 return Err(DbError::Message(
103 "active Store publication changed before completion".to_string(),
104 ));
105 }
106 Ok(())
107}
108
109pub(crate) fn clear_active_store_commit_for_owner_on(
110 conn: &rusqlite::Connection,
111 owner: &ActiveStorePublicationOwner,
112 candidate: &coven_protocol::store_commit::StoreBatchCommitRef,
113) -> Result<(), DbError> {
114 let active = load_active_store_publication_on(conn)?.ok_or_else(|| {
115 DbError::Message(format!(
116 "accepted Store candidate {candidate:?} has no active publication"
117 ))
118 })?;
119 if active.owner() != owner
120 || !matches!(
121 &active.attempt()?.entry.payload,
122 coven_protocol::store_commit::StorePublicationPayload::Commit(reference)
123 if reference == candidate
124 )
125 {
126 return Err(DbError::Message(format!(
127 "accepted Store candidate {candidate:?} differs from active publication {:?}",
128 active.owner()
129 )));
130 }
131 clear_active_store_publication_on(conn, &active)
132}
133
134pub(super) fn update_active_store_publication_on(
135 conn: &rusqlite::Connection,
136 expected: &ActiveStorePublication,
137 replacement: &ActiveStorePublication,
138) -> Result<(), DbError> {
139 let updated = conn
140 .execute(
141 "UPDATE active_store_publication SET state = ?1 \
142 WHERE singleton = 1 AND state = ?2",
143 [encode(replacement)?, encode(expected)?],
144 )
145 .map_err(DbError::from)?;
146 if updated != 1 {
147 return Err(DbError::Message(
148 "active Store publication changed before attempt replacement".to_string(),
149 ));
150 }
151 Ok(())
152}
153
154impl StoreSession<'_> {
155 fn active_store_publication(&self) -> Result<Option<ActiveStorePublication>, DbError> {
156 load_active_store_publication_on(self.conn)
157 }
158
159 fn replace_active_store_commit_publication(
160 &self,
161 commit: VerifiedStoreBatchCommit,
162 expected: ActiveStorePublication,
163 mut replacement: ActiveStorePublication,
164 ) -> Result<(), DbError> {
165 if expected.replace_attempt(replacement.attempt()?.clone())? != replacement {
166 return Err(DbError::Message(
167 "replacement changes the active Store publication owner or reservation".to_string(),
168 ));
169 }
170 if expected.commit_reservation()
171 != Some((
172 &commit.write_id,
173 &commit.author_registration,
174 &commit.reference().coord,
175 ))
176 || expected.attempt()?.entry.payload
177 != StorePublicationPayload::Commit(commit.reference().clone())
178 || expected.attempt()?.entry.payload != replacement.attempt()?.entry.payload
179 {
180 return Err(DbError::Message(
181 "Store candidate replacement must include its owning journal".to_string(),
182 ));
183 }
184 replacement.attempt()?.verify_commit(&commit)?;
185 replacement.attempt()?.prepared_entry()?;
186 let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
187 let installed =
188 super::observed_store_publication::load_store_current_publication_on(&transaction)?;
189 if installed.record() != &replacement.attempt()?.previous
190 || installed.observed_version() != Some(&replacement.attempt()?.previous_version)
191 {
192 return Err(DbError::Message(
193 "replacement Store publication does not extend the installed boundary".to_string(),
194 ));
195 }
196 let superseded = expected.attempt()?.reference()?;
197 let entries = self.store_publication_entries()?;
198 let winner = entries
199 .iter()
200 .find(|entry| entry.value.position == superseded.position)
201 .ok_or_else(|| {
202 DbError::Message(
203 "Store publication replacement lacks its settled exact position".to_string(),
204 )
205 })?;
206 if winner.prepared.reference() == &superseded.object {
207 return Err(DbError::Message(
208 "accepted Store publication cannot be replaced".to_string(),
209 ));
210 }
211 if let coven_protocol::store_commit::StorePublicationPayload::Commit(candidate) =
212 &expected.attempt()?.entry.payload
213 {
214 if entries.iter().any(|entry| {
215 matches!(
216 &entry.value.payload,
217 coven_protocol::store_commit::StorePublicationPayload::Commit(accepted)
218 if accepted.coord == candidate.coord
219 )
220 }) {
221 return Err(DbError::Message(
222 "accepted Store operation cannot receive another publication attempt"
223 .to_string(),
224 ));
225 }
226 }
227 replacement.retain_superseded_entry(superseded)?;
228 update_active_store_publication_on(&transaction, &expected, &replacement)?;
229 transaction.commit().map_err(DbError::from)
230 }
231
232 fn begin_retired_store_write_discard(
233 &self,
234 expected: ActiveStorePublication,
235 ) -> Result<ActiveStorePublication, DbError> {
236 let tx = self.conn.unchecked_transaction()?;
237 if load_active_store_publication_on(&tx)?.as_ref() != Some(&expected) {
238 return Err(DbError::Message(
239 "discarding Store publication changed".to_string(),
240 ));
241 }
242 let ActiveStorePublicationOwner::StoreWrite(write_id) = expected.owner() else {
243 return Err(DbError::Message(
244 "only a Store write can discard its captured rows".to_string(),
245 ));
246 };
247 let status: String = tx.query_row(
248 "SELECT status FROM store_writes WHERE write_id = ?1",
249 [write_id.as_str()],
250 |row| row.get(0),
251 )?;
252 let status: coven_protocol::write::WriteStatus = serde_json::from_str(&status)
253 .map_err(|error| DbError::context("write status before discard", error))?;
254 if !matches!(status, coven_protocol::write::WriteStatus::Blocked(_)) {
255 return Err(DbError::Message(
256 "retired write must be blocked before discard".to_string(),
257 ));
258 }
259 if expected.is_discarding() {
260 return Ok(expected);
261 }
262 let replacement = expected.begin_discard()?;
263 for retired in expected.retired_candidates() {
264 super::candidate_records::begin_candidate_nonactivation_targets_on(
265 &tx,
266 &retired.candidate()?,
267 &retired.objects()?,
268 &retired.nonactivation,
269 )?;
270 }
271 update_active_store_publication_on(&tx, &expected, &replacement)?;
272 tx.commit()?;
273 Ok(replacement)
274 }
275
276 fn retired_store_write_cleanup(
277 &self,
278 expected: &ActiveStorePublication,
279 ) -> Result<Vec<super::candidate_records::CandidateCleanupObject>, DbError> {
280 if load_active_store_publication_on(self.conn)?.as_ref() != Some(expected)
281 || (expected.is_awaiting_preparation()
282 && expected.owner() != &ActiveStorePublicationOwner::MembershipMutation)
283 {
284 return Err(DbError::Message(
285 "retired candidate cleanup requires its prepared replacement owner".to_string(),
286 ));
287 }
288 let mut targets = std::collections::BTreeMap::new();
289 for retired in expected.retired_candidates() {
290 for target in super::candidate_records::candidate_cleanup_targets_on(
291 self.conn,
292 &retired.candidate()?,
293 &retired.objects()?,
294 )? {
295 targets.insert(target.object.clone(), target);
296 }
297 }
298 Ok(targets.into_values().collect())
299 }
300
301 fn complete_retired_store_write_cleanup(
302 &self,
303 expected: ActiveStorePublication,
304 ) -> Result<(), DbError> {
305 let tx = self.conn.unchecked_transaction()?;
306 if load_active_store_publication_on(&tx)?.as_ref() != Some(&expected)
307 || (expected.is_awaiting_preparation()
308 && expected.owner() != &ActiveStorePublicationOwner::MembershipMutation)
309 {
310 return Err(DbError::Message(
311 "retired candidate owner changed before cleanup completion".to_string(),
312 ));
313 }
314 let targets = self.retired_store_write_cleanup(&expected)?;
315 let mut removed = Vec::new();
316 for target in targets {
317 let id = coven_protocol::remote_object::remote_object_id(&target.object);
318 let mut remote = crate::load_remote_object_on(&tx, id)?;
319 remote.mark_absent_verified()?;
320 crate::update_remote_object_on(&tx, id, &remote)?;
321 removed.push(id);
322 }
323 for retired in expected.retired_candidates() {
324 super::candidate_records::require_candidate_cleanup_complete_on(
325 &tx,
326 &retired.candidate()?,
327 &retired.objects()?,
328 "retired candidate cleanup is incomplete",
329 )?;
330 }
331 if expected.owner() == &ActiveStorePublicationOwner::MembershipMutation {
332 tx.commit()?;
335 return Ok(());
336 }
337 for retired in expected.retired_candidates() {
338 for blob in retired.blobs() {
339 let remote = crate::load_remote_object_on(&tx, blob.remote_object_id())?;
340 let verified = crate::PreparedAudienceBlob::from_remote(
341 blob.audience().clone(),
342 &blob.blob().locator().locator_hash().to_string(),
343 remote,
344 blob.spool_path().map(std::path::Path::to_path_buf),
345 )?;
346 if &verified != blob {
347 return Err(DbError::Message(
348 "retired blob source differs from its exact owner".to_string(),
349 ));
350 }
351 let Some(path) = blob.spool_path() else {
352 continue;
353 };
354 if path
355 != self
356 .store_dir
357 .outbound_blob_spool_path(blob.blob().locator().locator_hash())
358 {
359 return Err(DbError::Message(
360 "retired blob spool differs from its locator".to_string(),
361 ));
362 }
363 if retained_blob_spool_has_claim_on(&tx, path)? {
364 continue;
365 }
366 match std::fs::remove_file(path) {
367 Ok(()) => {}
368 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
369 Err(source) => {
370 return Err(coven_foundation::atomic_file::FileError::Path {
371 operation: "remove retired Store write blob spool",
372 path: path.to_path_buf(),
373 source,
374 }
375 .into());
376 }
377 }
378 coven_foundation::atomic_file::sync_parent_dir_blocking(path)?;
379 }
380 }
381 super::candidate_records::delete_remote_objects_on(
382 &tx,
383 removed,
384 "retired Store write candidate",
385 )?;
386 let mut completed = expected.clone();
387 completed.complete_retired_candidate_cleanup()?;
388 update_active_store_publication_on(&tx, &expected, &completed)?;
389 tx.commit()?;
390 Ok(())
391 }
392
393 fn complete_superseded_publication_cleanup(
394 &self,
395 expected: ActiveStorePublication,
396 ) -> Result<(), DbError> {
397 let mut completed = expected.clone();
398 completed.complete_superseded_entry_cleanup()?;
399 update_active_store_publication_on(self.conn, &expected, &completed)
400 }
401}
402
403impl StoreDatabase {
404 pub async fn begin_retired_store_write_discard(
405 &self,
406 expected: ActiveStorePublication,
407 ) -> Result<ActiveStorePublication, DbError> {
408 self.call_store(move |session| session.begin_retired_store_write_discard(expected))
409 .await
410 }
411
412 pub async fn active_store_publication(
413 &self,
414 ) -> Result<Option<ActiveStorePublication>, DbError> {
415 self.call_store(|session| session.active_store_publication())
416 .await
417 }
418
419 pub async fn replace_active_store_commit_publication(
420 &self,
421 commit: VerifiedStoreBatchCommit,
422 expected: ActiveStorePublication,
423 replacement: ActiveStorePublication,
424 ) -> Result<(), DbError> {
425 self.call_store(move |session| {
426 session.replace_active_store_commit_publication(commit, expected, replacement)
427 })
428 .await
429 }
430
431 pub async fn retired_store_write_cleanup(
432 &self,
433 expected: ActiveStorePublication,
434 ) -> Result<Vec<super::candidate_records::CandidateCleanupObject>, DbError> {
435 self.call_store(move |session| session.retired_store_write_cleanup(&expected))
436 .await
437 }
438
439 pub async fn complete_retired_store_write_cleanup(
440 &self,
441 expected: ActiveStorePublication,
442 ) -> Result<(), DbError> {
443 self.call_store(move |session| session.complete_retired_store_write_cleanup(expected))
444 .await
445 }
446
447 pub async fn complete_superseded_publication_cleanup(
448 &self,
449 expected: ActiveStorePublication,
450 ) -> Result<(), DbError> {
451 self.call_store(move |session| session.complete_superseded_publication_cleanup(expected))
452 .await
453 }
454}
455
456pub(super) fn retained_blob_spool_has_claim_on(
460 conn: &rusqlite::Connection,
461 path: &std::path::Path,
462) -> Result<bool, DbError> {
463 let encoded = path
464 .to_str()
465 .ok_or_else(|| DbError::Message("blob spool path is not UTF-8".to_string()))?;
466 if conn.query_row(
467 "SELECT EXISTS(SELECT 1 FROM store_write_blobs WHERE spool_path = ?1)",
468 [encoded],
469 |row| row.get::<_, bool>(0),
470 )? {
471 return Ok(true);
472 }
473 for encoded in crate::query_mapped_rows(
474 conn,
475 "SELECT upload_state FROM cloud_outbox WHERE operation = 'upload'",
476 [],
477 |row| row.get::<_, String>(0),
478 )? {
479 let state: super::blob_outbox::OutboxUploadState = serde_json::from_str(&encoded)
480 .map_err(|error| DbError::context("outbox blob spool owner", error))?;
481 match state {
482 super::blob_outbox::OutboxUploadState::Pending => {}
483 super::blob_outbox::OutboxUploadState::Prepared { spool_path, .. }
484 | super::blob_outbox::OutboxUploadState::Created { spool_path, .. }
485 if spool_path == path =>
486 {
487 return Ok(true);
488 }
489 super::blob_outbox::OutboxUploadState::Prepared { .. }
490 | super::blob_outbox::OutboxUploadState::Created { .. } => {}
491 }
492 }
493 Ok(false)
494}