Skip to main content

coven_database/store/store_session/
blob_outbox.rs

1use super::*;
2use crate::{MakeRemoteIntentState, OutboxIdentity};
3
4#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5#[serde(rename_all = "snake_case", deny_unknown_fields)]
6pub struct OutboxFailure {
7    pub message: String,
8    pub kind: OutboxFailureKind,
9}
10
11impl OutboxFailure {
12    pub fn other(message: impl Into<String>) -> Self {
13        Self {
14            message: message.into(),
15            kind: OutboxFailureKind::Other,
16        }
17    }
18
19    pub fn source_unavailable(path: std::path::PathBuf, message: impl Into<String>) -> Self {
20        Self {
21            message: message.into(),
22            kind: OutboxFailureKind::SourceUnavailable { path },
23        }
24    }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[serde(rename_all = "snake_case", deny_unknown_fields)]
29pub enum OutboxFailureKind {
30    Other,
31    SourceUnavailable { path: std::path::PathBuf },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct OutboxEntry {
36    pub id: i64,
37    pub attempt_count: i64,
38    pub last_attempt_at: Option<String>,
39    pub operation: OutboxOperation,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum OutboxOperation {
44    Upload {
45        root_table: String,
46        root_id: String,
47        row: coven_protocol::blob::RowBlobRef,
48        source_path: std::path::PathBuf,
49        retain_pinned: bool,
50        state: OutboxUploadState,
51    },
52    Delete {
53        stored: coven_protocol::blob::locator::StoredBlobRef,
54    },
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "snake_case", deny_unknown_fields)]
59pub enum OutboxUploadState {
60    Pending,
61    Prepared {
62        authority: coven_protocol::audience_package::PackageAudience,
63        stored: coven_protocol::blob::locator::StoredBlobRef,
64        spool_path: std::path::PathBuf,
65    },
66    Created {
67        authority: coven_protocol::audience_package::PackageAudience,
68        stored: coven_protocol::blob::locator::StoredBlobRef,
69        spool_path: std::path::PathBuf,
70    },
71}
72
73/// How far a gated root's make-remote transition has got, as its durable
74/// intent records it. The write id a publication carries is bookkeeping the
75/// transition owns, so it is not part of this.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MakeRemoteProgress {
78    /// Blobs are queued or uploading.
79    Uploading,
80    /// A cancellation is unwinding the transition.
81    Cancelling,
82    /// Every upload landed; the Store write that publishes them is pending.
83    Publishing,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum QueuedUploadPhase {
88    Pending,
89    Prepared,
90    Created,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct QueuedMakeRemote {
95    pub root_table: String,
96    pub root_id: String,
97    /// What the host called this root when it queued the work, snapshotted here
98    /// so the queue can name its own entries. The root row is exactly what a
99    /// cancelled or deleted root no longer has, and an entry that cannot say
100    /// what it is is an entry a host cannot render or a person cancel.
101    pub root_label: String,
102    pub retain_pinned: bool,
103    pub progress: MakeRemoteProgress,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct CloudOutboxSnapshot {
108    pub uploads: Vec<QueuedUpload>,
109    pub deletes: Vec<QueuedDelete>,
110    pub make_remotes: Vec<QueuedMakeRemote>,
111}
112
113/// One cloud object the durable queue is holding a tombstone for.
114///
115/// A delete carries only the stored object it removes — there is no row left to
116/// name, which is the point: the row is gone and this is what still has to
117/// happen in the cloud.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct QueuedDelete {
120    /// The namespace the removed blob lived in.
121    pub namespace: String,
122    /// The removed blob's id within that namespace.
123    pub blob_id: String,
124    /// Failed removal attempts so far; 0 for one never yet tried.
125    pub attempt_count: u64,
126    /// Why the last attempt failed, if one has.
127    pub last_error: Option<String>,
128    /// When the tombstone was enqueued.
129    pub created_at: String,
130    /// When it was last attempted, if it has been.
131    pub last_attempt_at: Option<String>,
132}
133
134/// One upload the durable cloud queue is holding, as a host renders it.
135///
136/// This is a projection of the queue row, not the drain's working entry: it
137/// carries what a person needs to see — which blob, which row it belongs to,
138/// whether it is retried and why — and none of the transfer bookkeeping the
139/// drain needs. `attempt_count` is 0 and `last_error` is `None` until a
140/// transfer has actually been tried and failed, so a freshly queued upload is
141/// distinguishable from a retrying one.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct QueuedUpload {
144    /// The exact blob-bearing row version held by the durable queue. This is the
145    /// same identity upload lifecycle callbacks report.
146    pub blob: coven_protocol::blob::RowBlobRef,
147    /// The gated root whose make-remote enqueued this upload. Every upload for
148    /// one root shares this pair, and the root is what a host groups by.
149    pub root_table: String,
150    pub root_id: String,
151    /// What the host called this root when it queued the work, snapshotted here
152    /// so the queue can name its own entries. The root row is exactly what a
153    /// cancelled or deleted root no longer has, and an entry that cannot say
154    /// what it is is an entry a host cannot render or a person cancel.
155    pub root_label: String,
156    /// Whether the transition asked for the plaintext to stay cached locally
157    /// once the upload lands.
158    pub retain_pinned: bool,
159    /// The durable handoff this exact upload has reached. In-memory transfer
160    /// activity refines this into Preparing or Uploading; after a restart this
161    /// is the authoritative lower bound the host renders immediately.
162    pub phase: QueuedUploadPhase,
163    /// Exact bytes the cloud provider receives once preparation has produced
164    /// the stored object. Pending preparation has no provider denominator yet.
165    pub provider_bytes_total: Option<u64>,
166    /// Failed transfer attempts so far; 0 for an upload never yet tried.
167    pub attempt_count: u64,
168    /// Why the last attempt failed, if one has.
169    pub last_failure: Option<OutboxFailure>,
170    /// When the upload was enqueued.
171    pub created_at: String,
172    /// When it was last attempted, if it has been.
173    pub last_attempt_at: Option<String>,
174}
175
176#[derive(Clone)]
177pub struct PublishedBlobDropIntent {
178    pub seq: u64,
179    pub drop: coven_protocol::blob::DeferredLocalBlobDrop,
180}
181
182impl StoreSession<'_> {
183    fn queued_upload_rows(
184        &mut self,
185        root: Option<(String, String)>,
186    ) -> Result<Vec<QueuedUpload>, DbError> {
187        const COLUMNS: &str = "SELECT row_ref, root_table, root_id, root_label, retain_pinned,
188                    upload_state, attempt_count, last_error, created_at, last_attempt_at
189             FROM cloud_outbox WHERE operation = 'upload'";
190        let (sql, parameters): (String, Vec<String>) = match root {
191            Some((root_table, root_id)) => (
192                format!("{COLUMNS} AND root_table = ?1 AND root_id = ?2 ORDER BY id"),
193                vec![root_table, root_id],
194            ),
195            None => (format!("{COLUMNS} ORDER BY id"), Vec::new()),
196        };
197        let mut statement = self.conn.prepare(&sql).map_err(DbError::from)?;
198        let uploads = statement
199            .query_map(rusqlite::params_from_iter(parameters), row_to_queued_upload)
200            .map_err(DbError::from)?
201            .collect::<Result<Vec<_>, _>>()
202            .map_err(DbError::from)?;
203        Ok(uploads)
204    }
205
206    fn queued_deletes(&mut self) -> Result<Vec<QueuedDelete>, DbError> {
207        let mut statement = self
208            .conn
209            .prepare(
210                "SELECT stored_ref, attempt_count, last_error, created_at, last_attempt_at
211                 FROM cloud_outbox WHERE operation = 'delete' ORDER BY id",
212            )
213            .map_err(DbError::from)?;
214        let deletes = statement
215            .query_map([], row_to_queued_delete)
216            .map_err(DbError::from)?
217            .collect::<Result<Vec<_>, _>>()
218            .map_err(DbError::from)?;
219        Ok(deletes)
220    }
221
222    fn queued_make_remotes(&mut self) -> Result<Vec<QueuedMakeRemote>, DbError> {
223        let mut statement = self
224            .conn
225            .prepare(
226                "SELECT root_table, root_id, root_label, retain_pinned, state
227                 FROM blob_make_remote_intents ORDER BY root_table, root_id",
228            )
229            .map_err(DbError::from)?;
230        let make_remotes = statement
231            .query_map([], |row| {
232                let state: String = row.get(4)?;
233                let progress = match state.as_str() {
234                    "uploading" => MakeRemoteProgress::Uploading,
235                    "cancelling" => MakeRemoteProgress::Cancelling,
236                    "publishing" => MakeRemoteProgress::Publishing,
237                    _ => {
238                        return Err(rusqlite::Error::FromSqlConversionFailure(
239                            4,
240                            rusqlite::types::Type::Text,
241                            Box::new(std::io::Error::other(format!(
242                                "invalid make_remote state {state:?}"
243                            ))),
244                        ))
245                    }
246                };
247                Ok(QueuedMakeRemote {
248                    root_table: row.get(0)?,
249                    root_id: row.get(1)?,
250                    root_label: row.get(2)?,
251                    retain_pinned: row.get(3)?,
252                    progress,
253                })
254            })
255            .map_err(DbError::from)?
256            .collect::<Result<Vec<_>, _>>()
257            .map_err(DbError::from)?;
258        Ok(make_remotes)
259    }
260
261    fn cloud_outbox_snapshot(&mut self) -> Result<CloudOutboxSnapshot, DbError> {
262        Ok(CloudOutboxSnapshot {
263            uploads: self.queued_upload_rows(None)?,
264            deletes: self.queued_deletes()?,
265            make_remotes: self.queued_make_remotes()?,
266        })
267    }
268
269    fn pending_outbox(&mut self, operation: &'static str) -> Result<Vec<OutboxEntry>, DbError> {
270        let mut statement = self
271            .conn
272            .prepare(
273                "SELECT id, operation, row_ref, stored_ref, source_path, retain_pinned,
274                        upload_state, attempt_count, last_attempt_at, root_table, root_id
275                 FROM cloud_outbox WHERE operation = ?1 ORDER BY id",
276            )
277            .map_err(DbError::from)?;
278        let entries = statement
279            .query_map([operation], crate::row_to_outbox_entry)
280            .map_err(DbError::from)?
281            .collect::<Result<Vec<_>, _>>()
282            .map_err(DbError::from)?;
283        Ok(entries)
284    }
285
286    fn remove_blob_delete(&mut self, id: i64, stored: String) -> Result<(), DbError> {
287        let removed = self
288            .conn
289            .execute(
290                "DELETE FROM cloud_outbox
291                 WHERE id = ?1 AND operation = 'delete' AND stored_ref = ?2",
292                rusqlite::params![id, stored],
293            )
294            .map_err(DbError::from)?;
295        if removed != 1 {
296            return Err(DbError::Message(
297                "blob delete outbox entry changed before exact dequeue".to_string(),
298            ));
299        }
300        Ok(())
301    }
302
303    fn published_blob_drop_intents(
304        &mut self,
305        max_seq: u64,
306    ) -> Result<Vec<PublishedBlobDropIntent>, DbError> {
307        let mut statement = self
308            .conn
309            .prepare(
310                "SELECT seq, namespace, blob_id, size, plaintext_hash, locator_hash, disposition
311                 FROM published_blob_drop_intents
312                 WHERE seq <= ?1
313                   AND NOT EXISTS (
314                       SELECT 1 FROM store_write_blob_leases lease
315                       WHERE lease.namespace = published_blob_drop_intents.namespace
316                         AND lease.blob_id = published_blob_drop_intents.blob_id
317                   )
318                   AND NOT EXISTS (
319                       SELECT 1 FROM retained_replay_blob_leases baseline
320                       WHERE baseline.namespace = published_blob_drop_intents.namespace
321                         AND baseline.blob_id = published_blob_drop_intents.blob_id
322                   )
323                 ORDER BY seq, namespace, blob_id, locator_hash",
324            )
325            .map_err(DbError::from)?;
326        let intents = statement
327            .query_map([max_seq as i64], row_to_published_blob_drop_intent)
328            .map_err(DbError::from)?
329            .collect::<Result<Vec<_>, _>>()
330            .map_err(DbError::from)?;
331        Ok(intents)
332    }
333
334    fn clear_published_blob_drop_intent(
335        &mut self,
336        seq: u64,
337        namespace: String,
338        id: String,
339        locator_hash: String,
340    ) -> Result<(), DbError> {
341        self.conn
342            .execute(
343                "DELETE FROM published_blob_drop_intents
344                 WHERE seq = ?1 AND namespace = ?2 AND blob_id = ?3 AND locator_hash = ?4",
345                rusqlite::params![seq as i64, namespace, id, locator_hash],
346            )
347            .map(|_| ())
348            .map_err(DbError::from)
349    }
350
351    fn record_outbox_failure(
352        &mut self,
353        entry: OutboxEntry,
354        failure: OutboxFailure,
355        attempted_at: String,
356    ) -> Result<(), DbError> {
357        let identity = crate::outbox_identity(&entry.operation)?;
358        let encoded = serde_json::to_string(&failure)
359            .map_err(|error| DbError::context("serialize outbox failure", error))?;
360        let updated = match identity {
361            OutboxIdentity::Upload {
362                table,
363                row_id,
364                column,
365                row_stamp,
366            } => self.conn.execute(
367                "UPDATE cloud_outbox SET attempt_count = attempt_count + 1,
368                 last_error = ?1, last_attempt_at = ?2
369                 WHERE id = ?3 AND operation = 'upload' AND table_name = ?4
370                   AND row_id = ?5 AND column_name = ?6 AND row_stamp = ?7",
371                rusqlite::params![
372                    encoded,
373                    attempted_at,
374                    entry.id,
375                    table,
376                    row_id,
377                    column,
378                    row_stamp
379                ],
380            ),
381            OutboxIdentity::Stored { operation, stored } => self.conn.execute(
382                "UPDATE cloud_outbox SET attempt_count = attempt_count + 1,
383                     last_error = ?1, last_attempt_at = ?2
384                     WHERE id = ?3 AND operation = ?4 AND stored_ref = ?5",
385                rusqlite::params![encoded, attempted_at, entry.id, operation, stored],
386            ),
387        }
388        .map_err(DbError::from)?;
389        if updated != 1 {
390            return Err(DbError::Message(
391                "cloud outbox entry changed before failure recording".to_string(),
392            ));
393        }
394        Ok(())
395    }
396
397    #[allow(clippy::too_many_arguments)]
398    fn swap_blob_upload_state(
399        &mut self,
400        id: i64,
401        table: String,
402        row_id: String,
403        column: String,
404        row_stamp: String,
405        from: String,
406        to: String,
407        context: &'static str,
408    ) -> Result<(), DbError> {
409        let updated = self
410            .conn
411            .execute(
412                "UPDATE cloud_outbox SET upload_state = ?1, last_error = NULL
413                 WHERE id = ?2 AND operation = 'upload' AND table_name = ?3
414                   AND row_id = ?4 AND column_name = ?5 AND row_stamp = ?6
415                   AND upload_state = ?7",
416                rusqlite::params![to, id, table, row_id, column, row_stamp, from],
417            )
418            .map_err(DbError::from)?;
419        if updated != 1 {
420            return Err(DbError::Message(format!(
421                "upload outbox entry changed before {context}"
422            )));
423        }
424        Ok(())
425    }
426
427    fn reset_outbox_backoff(&mut self) -> Result<(), DbError> {
428        self.conn
429            .execute(
430                "UPDATE cloud_outbox SET last_attempt_at = NULL WHERE attempt_count > 0",
431                [],
432            )
433            .map(|_| ())
434            .map_err(DbError::from)
435    }
436
437    fn make_remote_intent_state(
438        &mut self,
439        root_table: String,
440        root_id: String,
441    ) -> Result<Option<MakeRemoteIntentState>, DbError> {
442        Database::make_remote_intent_state(self.conn, &root_table, &root_id)
443    }
444
445    fn finish_cancelled_blob_upload(&mut self, entry: OutboxEntry) -> Result<bool, DbError> {
446        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
447        let finished =
448            crate::CloudOutboxRecords::new(&transaction).finish_cancelled_upload(&entry)?;
449        transaction.commit().map_err(DbError::from)?;
450        Ok(finished)
451    }
452}
453
454pub(super) fn take_leased_published_blob_drop_intents_for_restoration_on(
455    conn: &rusqlite::Connection,
456    blobs: &[coven_protocol::blob::BlobRef],
457) -> Result<Vec<PublishedBlobDropIntent>, DbError> {
458    let blobs = blobs
459        .iter()
460        .map(|blob| (blob.namespace.as_str(), blob.id.as_str()))
461        .collect::<std::collections::BTreeSet<_>>();
462    let mut taken = Vec::new();
463    for (namespace, blob_id) in blobs {
464        let intents = {
465            let mut statement = conn
466                .prepare(
467                    "SELECT seq, namespace, blob_id, size, plaintext_hash, locator_hash, disposition
468                     FROM published_blob_drop_intents
469                     WHERE namespace = ?1 AND blob_id = ?2
470                       AND (
471                           EXISTS (
472                               SELECT 1 FROM store_write_blob_leases
473                               WHERE namespace = ?1 AND blob_id = ?2
474                           ) OR EXISTS (
475                               SELECT 1 FROM retained_replay_blob_leases
476                               WHERE namespace = ?1 AND blob_id = ?2
477                           )
478                       )
479                     ORDER BY seq, locator_hash",
480                )
481                .map_err(DbError::from)?;
482            let intents = statement
483                .query_map((namespace, blob_id), row_to_published_blob_drop_intent)
484                .map_err(DbError::from)?
485                .collect::<Result<Vec<_>, _>>()
486                .map_err(DbError::from)?;
487            intents
488        };
489        for intent in intents {
490            let removed = crate::with_coven_sql_authority(|| {
491                conn.execute(
492                    "DELETE FROM published_blob_drop_intents
493                     WHERE seq = ?1 AND namespace = ?2 AND blob_id = ?3 AND locator_hash = ?4",
494                    rusqlite::params![
495                        i64::try_from(intent.seq).map_err(|_| DbError::Message(format!(
496                            "published blob drop sequence {} exceeds SQLite integer range",
497                            intent.seq
498                        )))?,
499                        intent.drop.namespace,
500                        intent.drop.id,
501                        intent.drop.locator_hash.to_string(),
502                    ],
503                )
504                .map_err(DbError::from)
505            })?;
506            if removed != 1 {
507                return Err(DbError::Message(format!(
508                    "published blob drop intent changed while restoring {namespace}/{blob_id}"
509                )));
510            }
511            taken.push(intent);
512        }
513    }
514    Ok(taken)
515}
516
517pub(super) fn reinsert_published_blob_drop_intent_on(
518    conn: &rusqlite::Connection,
519    intent: &PublishedBlobDropIntent,
520) -> Result<(), DbError> {
521    let inserted = crate::with_coven_sql_authority(|| {
522        conn.execute(
523            "INSERT INTO published_blob_drop_intents
524             (seq, namespace, blob_id, size, plaintext_hash, locator_hash, disposition)
525             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
526            rusqlite::params![
527                i64::try_from(intent.seq).map_err(|_| DbError::Message(format!(
528                    "published blob drop sequence {} exceeds SQLite integer range",
529                    intent.seq
530                )))?,
531                intent.drop.namespace,
532                intent.drop.id,
533                i64::try_from(intent.drop.size).map_err(|_| DbError::Message(format!(
534                    "published blob drop size {} exceeds SQLite integer range",
535                    intent.drop.size
536                )))?,
537                intent.drop.plaintext_hash.to_string(),
538                intent.drop.locator_hash.to_string(),
539                intent.drop.disposition.as_db(),
540            ],
541        )
542        .map_err(DbError::from)
543    })?;
544    if inserted != 1 {
545        return Err(DbError::Message(
546            "published blob drop intent was not restored".to_string(),
547        ));
548    }
549    Ok(())
550}
551
552impl StoreDatabase {
553    #[doc(hidden)]
554    pub async fn cloud_outbox_snapshot(&self) -> Result<CloudOutboxSnapshot, DbError> {
555        self.call_store(|session| session.cloud_outbox_snapshot())
556            .await
557    }
558
559    #[doc(hidden)]
560    pub async fn queued_uploads(&self) -> Result<Vec<QueuedUpload>, DbError> {
561        self.queued_upload_rows(None).await
562    }
563
564    #[doc(hidden)]
565    pub async fn queued_uploads_for_root(
566        &self,
567        root_table: &str,
568        root_id: &str,
569    ) -> Result<Vec<QueuedUpload>, DbError> {
570        self.queued_upload_rows(Some((root_table.to_string(), root_id.to_string())))
571            .await
572    }
573
574    async fn queued_upload_rows(
575        &self,
576        root: Option<(String, String)>,
577    ) -> Result<Vec<QueuedUpload>, DbError> {
578        self.call_store(move |session| session.queued_upload_rows(root))
579            .await
580    }
581
582    #[doc(hidden)]
583    pub async fn queued_deletes(&self) -> Result<Vec<QueuedDelete>, DbError> {
584        self.call_store(|session| session.queued_deletes()).await
585    }
586
587    pub async fn pending_blob_deletes(&self) -> Result<Vec<OutboxEntry>, DbError> {
588        self.pending_outbox("delete").await
589    }
590
591    async fn pending_outbox(&self, operation: &'static str) -> Result<Vec<OutboxEntry>, DbError> {
592        self.call_store(move |session| session.pending_outbox(operation))
593            .await
594    }
595
596    pub async fn remove_blob_delete(&self, entry: &OutboxEntry) -> Result<(), DbError> {
597        let OutboxOperation::Delete { stored } = &entry.operation else {
598            return Err(DbError::Message(
599                "blob delete dequeue requires a delete outbox entry".to_string(),
600            ));
601        };
602        let id = entry.id;
603        let stored = serde_json::to_string(stored)
604            .map_err(|error| DbError::context("serialize stored blob ref", error))?;
605        self.call_store(move |session| session.remove_blob_delete(id, stored))
606            .await
607    }
608
609    pub async fn published_blob_drop_intents(
610        &self,
611        max_seq: u64,
612    ) -> Result<Vec<PublishedBlobDropIntent>, DbError> {
613        self.call_store(move |session| session.published_blob_drop_intents(max_seq))
614            .await
615    }
616
617    pub async fn clear_published_blob_drop_intent(
618        &self,
619        intent: &PublishedBlobDropIntent,
620    ) -> Result<(), DbError> {
621        let seq = intent.seq;
622        let namespace = intent.drop.namespace.clone();
623        let id = intent.drop.id.clone();
624        let locator_hash = intent.drop.locator_hash.to_string();
625        self.call_store(move |session| {
626            session.clear_published_blob_drop_intent(seq, namespace, id, locator_hash)
627        })
628        .await
629    }
630
631    pub async fn pending_blob_uploads(&self) -> Result<Vec<OutboxEntry>, DbError> {
632        self.pending_outbox("upload").await
633    }
634
635    pub async fn mark_blob_upload_prepared(
636        &self,
637        entry: &OutboxEntry,
638        authority: coven_protocol::audience_package::PackageAudience,
639        stored: coven_protocol::blob::locator::StoredBlobRef,
640        spool_path: std::path::PathBuf,
641    ) -> Result<(), DbError> {
642        let OutboxOperation::Upload { row, state, .. } = &entry.operation else {
643            return Err(DbError::Message(
644                "only an upload outbox entry can own a prepared blob".to_string(),
645            ));
646        };
647        if state != &OutboxUploadState::Pending {
648            return Err(DbError::Message(
649                "blob upload is already prepared".to_string(),
650            ));
651        }
652        let locator = stored.locator();
653        if !coven_protocol::blob::locator_describes_row(
654            locator,
655            row.blob(),
656            row.plaintext_size(),
657            row.plaintext_hash(),
658        ) {
659            return Err(DbError::Message(
660                "prepared blob differs from its exact Local row version".to_string(),
661            ));
662        }
663        if locator.audience() != authority.remote_audience() {
664            return Err(DbError::Message(
665                "prepared blob audience differs from its package authority".to_string(),
666            ));
667        }
668        let prepared = OutboxUploadState::Prepared {
669            authority,
670            stored,
671            spool_path,
672        };
673        let prepared_json = serde_json::to_string(&prepared)
674            .map_err(|error| DbError::context("serialize prepared blob upload", error))?;
675        let pending_json = serde_json::to_string(&OutboxUploadState::Pending)
676            .map_err(|error| DbError::context("serialize pending blob upload", error))?;
677        self.swap_blob_upload_state(
678            entry.id,
679            row,
680            pending_json,
681            prepared_json,
682            "prepared-object handoff",
683        )
684        .await
685    }
686
687    pub async fn mark_blob_upload_created(&self, entry: &OutboxEntry) -> Result<(), DbError> {
688        let OutboxOperation::Upload { row, state, .. } = &entry.operation else {
689            return Err(DbError::Message(
690                "only a prepared upload outbox entry can record cloud creation".to_string(),
691            ));
692        };
693        let OutboxUploadState::Prepared {
694            authority,
695            stored,
696            spool_path,
697        } = state
698        else {
699            return Err(DbError::Message(
700                "cloud creation requires a prepared upload object".to_string(),
701            ));
702        };
703        let created_json = serde_json::to_string(&OutboxUploadState::Created {
704            authority: authority.clone(),
705            stored: stored.clone(),
706            spool_path: spool_path.clone(),
707        })
708        .map_err(|error| DbError::context("serialize created blob upload", error))?;
709        let prepared_json = serde_json::to_string(state)
710            .map_err(|error| DbError::context("serialize prepared blob upload identity", error))?;
711        self.swap_blob_upload_state(
712            entry.id,
713            row,
714            prepared_json,
715            created_json,
716            "cloud-created handoff",
717        )
718        .await
719    }
720
721    pub async fn record_outbox_failure(
722        &self,
723        entry: &OutboxEntry,
724        failure: OutboxFailure,
725        attempted_at: &str,
726    ) -> Result<(), DbError> {
727        let entry = entry.clone();
728        let attempted_at = attempted_at.to_string();
729        self.call_store(move |session| session.record_outbox_failure(entry, failure, attempted_at))
730            .await
731    }
732
733    async fn swap_blob_upload_state(
734        &self,
735        id: i64,
736        row: &coven_protocol::blob::RowBlobRef,
737        from: String,
738        to: String,
739        context: &'static str,
740    ) -> Result<(), DbError> {
741        let table = row.table().to_string();
742        let row_id = row.row_id().to_string();
743        let column = row.column().to_string();
744        let row_stamp = row.row_stamp().to_string();
745        self.call_store(move |session| {
746            session.swap_blob_upload_state(id, table, row_id, column, row_stamp, from, to, context)
747        })
748        .await
749    }
750
751    pub async fn reset_outbox_backoff(&self) -> Result<(), DbError> {
752        self.call_store(|session| session.reset_outbox_backoff())
753            .await
754    }
755
756    pub async fn make_remote_intent_state(
757        &self,
758        root_table: &str,
759        root_id: &str,
760    ) -> Result<Option<MakeRemoteIntentState>, DbError> {
761        let root_table = root_table.to_string();
762        let root_id = root_id.to_string();
763        self.call_store(move |session| session.make_remote_intent_state(root_table, root_id))
764            .await
765    }
766
767    pub async fn make_remote_progress(
768        &self,
769        root_table: &str,
770        root_id: &str,
771    ) -> Result<Option<crate::MakeRemoteProgress>, DbError> {
772        Ok(self
773            .make_remote_intent_state(root_table, root_id)
774            .await?
775            .map(|state| match state {
776                MakeRemoteIntentState::Uploading => MakeRemoteProgress::Uploading,
777                MakeRemoteIntentState::Cancelling => MakeRemoteProgress::Cancelling,
778                MakeRemoteIntentState::Publishing(_) => MakeRemoteProgress::Publishing,
779            }))
780    }
781
782    pub async fn finish_cancelled_blob_upload(&self, entry: &OutboxEntry) -> Result<bool, DbError> {
783        let entry = entry.clone();
784        self.call_store(move |session| session.finish_cancelled_blob_upload(entry))
785            .await
786    }
787}
788
789fn row_to_queued_upload(row: &rusqlite::Row<'_>) -> rusqlite::Result<QueuedUpload> {
790    let invalid = |index: usize, source: Box<dyn std::error::Error + Send + Sync>| {
791        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, source)
792    };
793    let encoded: String = row.get(0)?;
794    let reference: coven_protocol::blob::RowBlobRef =
795        serde_json::from_str(&encoded).map_err(|error| invalid(0, Box::new(error)))?;
796    let state_json: String = row.get(5)?;
797    let state: OutboxUploadState =
798        serde_json::from_str(&state_json).map_err(|error| invalid(5, Box::new(error)))?;
799    let (phase, provider_bytes_total) = match &state {
800        OutboxUploadState::Pending => (QueuedUploadPhase::Pending, None),
801        OutboxUploadState::Prepared { stored, .. } => (
802            QueuedUploadPhase::Prepared,
803            Some(stored.object().stored_size()),
804        ),
805        OutboxUploadState::Created { stored, .. } => (
806            QueuedUploadPhase::Created,
807            Some(stored.object().stored_size()),
808        ),
809    };
810    let attempt_count: i64 = row.get(6)?;
811    let last_failure = row
812        .get::<_, Option<String>>(7)?
813        .map(|encoded| serde_json::from_str(&encoded).map_err(|error| invalid(7, Box::new(error))))
814        .transpose()?;
815    Ok(QueuedUpload {
816        blob: reference,
817        root_table: row.get(1)?,
818        root_id: row.get(2)?,
819        root_label: row.get(3)?,
820        retain_pinned: row.get(4)?,
821        phase,
822        provider_bytes_total,
823        attempt_count: u64::try_from(attempt_count).map_err(|error| invalid(6, Box::new(error)))?,
824        last_failure,
825        created_at: row.get(8)?,
826        last_attempt_at: row.get(9)?,
827    })
828}
829
830fn row_to_published_blob_drop_intent(
831    row: &rusqlite::Row<'_>,
832) -> rusqlite::Result<PublishedBlobDropIntent> {
833    let size: Option<i64> = row.get(3)?;
834    let size = size.ok_or_else(|| {
835        rusqlite::Error::FromSqlConversionFailure(
836            3,
837            rusqlite::types::Type::Integer,
838            Box::new(std::io::Error::new(
839                std::io::ErrorKind::InvalidData,
840                "published blob drop intent is missing size",
841            )),
842        )
843    })?;
844    if size < 0 {
845        return Err(rusqlite::Error::FromSqlConversionFailure(
846            3,
847            rusqlite::types::Type::Integer,
848            Box::new(std::io::Error::new(
849                std::io::ErrorKind::InvalidData,
850                format!("published blob drop intent has negative size {size}"),
851            )),
852        ));
853    }
854    let plaintext_hash = row.get::<_, String>(4)?.parse().map_err(|error| {
855        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(error))
856    })?;
857    let locator_hash = row.get::<_, String>(5)?.parse().map_err(|error| {
858        rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
859    })?;
860    let disposition_raw: String = row.get(6)?;
861    let disposition = coven_protocol::blob::DeferredLocalBlobDisposition::from_db(&disposition_raw)
862        .map_err(|message| {
863            rusqlite::Error::FromSqlConversionFailure(
864                6,
865                rusqlite::types::Type::Text,
866                Box::new(std::io::Error::new(
867                    std::io::ErrorKind::InvalidData,
868                    message,
869                )),
870            )
871        })?;
872    Ok(PublishedBlobDropIntent {
873        seq: row.get::<_, i64>(0)? as u64,
874        drop: coven_protocol::blob::DeferredLocalBlobDrop {
875            namespace: row.get(1)?,
876            id: row.get(2)?,
877            size: size as u64,
878            plaintext_hash,
879            locator_hash,
880            disposition,
881        },
882    })
883}
884
885fn row_to_queued_delete(row: &rusqlite::Row<'_>) -> rusqlite::Result<QueuedDelete> {
886    let invalid = |index: usize, source: Box<dyn std::error::Error + Send + Sync>| {
887        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, source)
888    };
889    let encoded: String = row.get(0)?;
890    let stored: coven_protocol::blob::locator::StoredBlobRef =
891        serde_json::from_str(&encoded).map_err(|error| invalid(0, Box::new(error)))?;
892    let attempt_count: i64 = row.get(1)?;
893    let last_error = row
894        .get::<_, Option<String>>(2)?
895        .map(|encoded| {
896            serde_json::from_str::<OutboxFailure>(&encoded)
897                .map(|failure| failure.message)
898                .map_err(|error| invalid(2, Box::new(error)))
899        })
900        .transpose()?;
901    Ok(QueuedDelete {
902        namespace: stored.locator().namespace().to_string(),
903        blob_id: stored.locator().blob_id().to_string(),
904        attempt_count: u64::try_from(attempt_count).map_err(|error| invalid(1, Box::new(error)))?,
905        last_error,
906        created_at: row.get(3)?,
907        last_attempt_at: row.get(4)?,
908    })
909}