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_published_blob_drop_intents_for_restoration_on(
455    conn: &rusqlite::Connection,
456    blobs: &[coven_protocol::blob::BlobRef],
457    can_restore: impl Fn(&PublishedBlobDropIntent, bool) -> Result<bool, DbError>,
458) -> Result<Vec<PublishedBlobDropIntent>, DbError> {
459    let blobs = blobs
460        .iter()
461        .map(|blob| (blob.namespace.as_str(), blob.id.as_str()))
462        .collect::<std::collections::BTreeSet<_>>();
463    let mut taken = Vec::new();
464    for (namespace, blob_id) in blobs {
465        let intents = {
466            let mut statement = conn
467                .prepare(
468                    "SELECT seq, namespace, blob_id, size, plaintext_hash, locator_hash, disposition,
469                       (
470                           EXISTS (
471                               SELECT 1 FROM store_write_blob_leases
472                               WHERE namespace = ?1 AND blob_id = ?2
473                           ) OR EXISTS (
474                               SELECT 1 FROM retained_replay_blob_leases
475                               WHERE namespace = ?1 AND blob_id = ?2
476                           )
477                       )
478                     FROM published_blob_drop_intents
479                     WHERE namespace = ?1 AND blob_id = ?2
480                     ORDER BY seq, locator_hash",
481                )
482                .map_err(DbError::from)?;
483            let intents = statement
484                .query_map((namespace, blob_id), |row| {
485                    Ok((
486                        row_to_published_blob_drop_intent(row)?,
487                        row.get::<_, bool>(7)?,
488                    ))
489                })
490                .map_err(DbError::from)?
491                .collect::<Result<Vec<_>, _>>()
492                .map_err(DbError::from)?;
493            intents
494        };
495        for (intent, leased) in intents {
496            if !can_restore(&intent, leased)? {
497                continue;
498            }
499            let removed = crate::with_coven_sql_authority(|| {
500                conn.execute(
501                    "DELETE FROM published_blob_drop_intents
502                     WHERE seq = ?1 AND namespace = ?2 AND blob_id = ?3 AND locator_hash = ?4",
503                    rusqlite::params![
504                        i64::try_from(intent.seq).map_err(|_| DbError::Message(format!(
505                            "published blob drop sequence {} exceeds SQLite integer range",
506                            intent.seq
507                        )))?,
508                        intent.drop.namespace,
509                        intent.drop.id,
510                        intent.drop.locator_hash.to_string(),
511                    ],
512                )
513                .map_err(DbError::from)
514            })?;
515            if removed != 1 {
516                return Err(DbError::Message(format!(
517                    "published blob drop intent changed while restoring {namespace}/{blob_id}"
518                )));
519            }
520            taken.push(intent);
521        }
522    }
523    Ok(taken)
524}
525
526pub(super) fn reinsert_published_blob_drop_intent_on(
527    conn: &rusqlite::Connection,
528    intent: &PublishedBlobDropIntent,
529) -> Result<(), DbError> {
530    let inserted = crate::with_coven_sql_authority(|| {
531        conn.execute(
532            "INSERT INTO published_blob_drop_intents
533             (seq, namespace, blob_id, size, plaintext_hash, locator_hash, disposition)
534             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
535            rusqlite::params![
536                i64::try_from(intent.seq).map_err(|_| DbError::Message(format!(
537                    "published blob drop sequence {} exceeds SQLite integer range",
538                    intent.seq
539                )))?,
540                intent.drop.namespace,
541                intent.drop.id,
542                i64::try_from(intent.drop.size).map_err(|_| DbError::Message(format!(
543                    "published blob drop size {} exceeds SQLite integer range",
544                    intent.drop.size
545                )))?,
546                intent.drop.plaintext_hash.to_string(),
547                intent.drop.locator_hash.to_string(),
548                intent.drop.disposition.as_db(),
549            ],
550        )
551        .map_err(DbError::from)
552    })?;
553    if inserted != 1 {
554        return Err(DbError::Message(
555            "published blob drop intent was not restored".to_string(),
556        ));
557    }
558    Ok(())
559}
560
561impl StoreDatabase {
562    #[doc(hidden)]
563    pub async fn cloud_outbox_snapshot(&self) -> Result<CloudOutboxSnapshot, DbError> {
564        self.call_store(|session| session.cloud_outbox_snapshot())
565            .await
566    }
567
568    #[doc(hidden)]
569    pub async fn queued_uploads(&self) -> Result<Vec<QueuedUpload>, DbError> {
570        self.queued_upload_rows(None).await
571    }
572
573    #[doc(hidden)]
574    pub async fn queued_uploads_for_root(
575        &self,
576        root_table: &str,
577        root_id: &str,
578    ) -> Result<Vec<QueuedUpload>, DbError> {
579        self.queued_upload_rows(Some((root_table.to_string(), root_id.to_string())))
580            .await
581    }
582
583    async fn queued_upload_rows(
584        &self,
585        root: Option<(String, String)>,
586    ) -> Result<Vec<QueuedUpload>, DbError> {
587        self.call_store(move |session| session.queued_upload_rows(root))
588            .await
589    }
590
591    #[doc(hidden)]
592    pub async fn queued_deletes(&self) -> Result<Vec<QueuedDelete>, DbError> {
593        self.call_store(|session| session.queued_deletes()).await
594    }
595
596    pub async fn pending_blob_deletes(&self) -> Result<Vec<OutboxEntry>, DbError> {
597        self.pending_outbox("delete").await
598    }
599
600    async fn pending_outbox(&self, operation: &'static str) -> Result<Vec<OutboxEntry>, DbError> {
601        self.call_store(move |session| session.pending_outbox(operation))
602            .await
603    }
604
605    pub async fn remove_blob_delete(&self, entry: &OutboxEntry) -> Result<(), DbError> {
606        let OutboxOperation::Delete { stored } = &entry.operation else {
607            return Err(DbError::Message(
608                "blob delete dequeue requires a delete outbox entry".to_string(),
609            ));
610        };
611        let id = entry.id;
612        let stored = serde_json::to_string(stored)
613            .map_err(|error| DbError::context("serialize stored blob ref", error))?;
614        self.call_store(move |session| session.remove_blob_delete(id, stored))
615            .await
616    }
617
618    pub async fn published_blob_drop_intents(
619        &self,
620        max_seq: u64,
621    ) -> Result<Vec<PublishedBlobDropIntent>, DbError> {
622        self.call_store(move |session| session.published_blob_drop_intents(max_seq))
623            .await
624    }
625
626    pub async fn clear_published_blob_drop_intent(
627        &self,
628        intent: &PublishedBlobDropIntent,
629    ) -> Result<(), DbError> {
630        let seq = intent.seq;
631        let namespace = intent.drop.namespace.clone();
632        let id = intent.drop.id.clone();
633        let locator_hash = intent.drop.locator_hash.to_string();
634        self.call_store(move |session| {
635            session.clear_published_blob_drop_intent(seq, namespace, id, locator_hash)
636        })
637        .await
638    }
639
640    pub async fn pending_blob_uploads(&self) -> Result<Vec<OutboxEntry>, DbError> {
641        self.pending_outbox("upload").await
642    }
643
644    pub async fn mark_blob_upload_prepared(
645        &self,
646        entry: &OutboxEntry,
647        authority: coven_protocol::audience_package::PackageAudience,
648        stored: coven_protocol::blob::locator::StoredBlobRef,
649        spool_path: std::path::PathBuf,
650    ) -> Result<(), DbError> {
651        let OutboxOperation::Upload { row, state, .. } = &entry.operation else {
652            return Err(DbError::Message(
653                "only an upload outbox entry can own a prepared blob".to_string(),
654            ));
655        };
656        if state != &OutboxUploadState::Pending {
657            return Err(DbError::Message(
658                "blob upload is already prepared".to_string(),
659            ));
660        }
661        let locator = stored.locator();
662        if !coven_protocol::blob::locator_describes_row(
663            locator,
664            row.blob(),
665            row.plaintext_size(),
666            row.plaintext_hash(),
667        ) {
668            return Err(DbError::Message(
669                "prepared blob differs from its exact Local row version".to_string(),
670            ));
671        }
672        if locator.audience() != authority.remote_audience() {
673            return Err(DbError::Message(
674                "prepared blob audience differs from its package authority".to_string(),
675            ));
676        }
677        let prepared = OutboxUploadState::Prepared {
678            authority,
679            stored,
680            spool_path,
681        };
682        let prepared_json = serde_json::to_string(&prepared)
683            .map_err(|error| DbError::context("serialize prepared blob upload", error))?;
684        let pending_json = serde_json::to_string(&OutboxUploadState::Pending)
685            .map_err(|error| DbError::context("serialize pending blob upload", error))?;
686        self.swap_blob_upload_state(
687            entry.id,
688            row,
689            pending_json,
690            prepared_json,
691            "prepared-object handoff",
692        )
693        .await
694    }
695
696    pub async fn mark_blob_upload_created(&self, entry: &OutboxEntry) -> Result<(), DbError> {
697        let OutboxOperation::Upload { row, state, .. } = &entry.operation else {
698            return Err(DbError::Message(
699                "only a prepared upload outbox entry can record cloud creation".to_string(),
700            ));
701        };
702        let OutboxUploadState::Prepared {
703            authority,
704            stored,
705            spool_path,
706        } = state
707        else {
708            return Err(DbError::Message(
709                "cloud creation requires a prepared upload object".to_string(),
710            ));
711        };
712        let created_json = serde_json::to_string(&OutboxUploadState::Created {
713            authority: authority.clone(),
714            stored: stored.clone(),
715            spool_path: spool_path.clone(),
716        })
717        .map_err(|error| DbError::context("serialize created blob upload", error))?;
718        let prepared_json = serde_json::to_string(state)
719            .map_err(|error| DbError::context("serialize prepared blob upload identity", error))?;
720        self.swap_blob_upload_state(
721            entry.id,
722            row,
723            prepared_json,
724            created_json,
725            "cloud-created handoff",
726        )
727        .await
728    }
729
730    pub async fn record_outbox_failure(
731        &self,
732        entry: &OutboxEntry,
733        failure: OutboxFailure,
734        attempted_at: &str,
735    ) -> Result<(), DbError> {
736        let entry = entry.clone();
737        let attempted_at = attempted_at.to_string();
738        self.call_store(move |session| session.record_outbox_failure(entry, failure, attempted_at))
739            .await
740    }
741
742    async fn swap_blob_upload_state(
743        &self,
744        id: i64,
745        row: &coven_protocol::blob::RowBlobRef,
746        from: String,
747        to: String,
748        context: &'static str,
749    ) -> Result<(), DbError> {
750        let table = row.table().to_string();
751        let row_id = row.row_id().to_string();
752        let column = row.column().to_string();
753        let row_stamp = row.row_stamp().to_string();
754        self.call_store(move |session| {
755            session.swap_blob_upload_state(id, table, row_id, column, row_stamp, from, to, context)
756        })
757        .await
758    }
759
760    pub async fn reset_outbox_backoff(&self) -> Result<(), DbError> {
761        self.call_store(|session| session.reset_outbox_backoff())
762            .await
763    }
764
765    pub async fn make_remote_intent_state(
766        &self,
767        root_table: &str,
768        root_id: &str,
769    ) -> Result<Option<MakeRemoteIntentState>, DbError> {
770        let root_table = root_table.to_string();
771        let root_id = root_id.to_string();
772        self.call_store(move |session| session.make_remote_intent_state(root_table, root_id))
773            .await
774    }
775
776    pub async fn make_remote_progress(
777        &self,
778        root_table: &str,
779        root_id: &str,
780    ) -> Result<Option<crate::MakeRemoteProgress>, DbError> {
781        Ok(self
782            .make_remote_intent_state(root_table, root_id)
783            .await?
784            .map(|state| match state {
785                MakeRemoteIntentState::Uploading => MakeRemoteProgress::Uploading,
786                MakeRemoteIntentState::Cancelling => MakeRemoteProgress::Cancelling,
787                MakeRemoteIntentState::Publishing(_) => MakeRemoteProgress::Publishing,
788            }))
789    }
790
791    pub async fn finish_cancelled_blob_upload(&self, entry: &OutboxEntry) -> Result<bool, DbError> {
792        let entry = entry.clone();
793        self.call_store(move |session| session.finish_cancelled_blob_upload(entry))
794            .await
795    }
796}
797
798fn row_to_queued_upload(row: &rusqlite::Row<'_>) -> rusqlite::Result<QueuedUpload> {
799    let invalid = |index: usize, source: Box<dyn std::error::Error + Send + Sync>| {
800        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, source)
801    };
802    let encoded: String = row.get(0)?;
803    let reference: coven_protocol::blob::RowBlobRef =
804        serde_json::from_str(&encoded).map_err(|error| invalid(0, Box::new(error)))?;
805    let state_json: String = row.get(5)?;
806    let state: OutboxUploadState =
807        serde_json::from_str(&state_json).map_err(|error| invalid(5, Box::new(error)))?;
808    let (phase, provider_bytes_total) = match &state {
809        OutboxUploadState::Pending => (QueuedUploadPhase::Pending, None),
810        OutboxUploadState::Prepared { stored, .. } => (
811            QueuedUploadPhase::Prepared,
812            Some(stored.object().stored_size()),
813        ),
814        OutboxUploadState::Created { stored, .. } => (
815            QueuedUploadPhase::Created,
816            Some(stored.object().stored_size()),
817        ),
818    };
819    let attempt_count: i64 = row.get(6)?;
820    let last_failure = row
821        .get::<_, Option<String>>(7)?
822        .map(|encoded| serde_json::from_str(&encoded).map_err(|error| invalid(7, Box::new(error))))
823        .transpose()?;
824    Ok(QueuedUpload {
825        blob: reference,
826        root_table: row.get(1)?,
827        root_id: row.get(2)?,
828        root_label: row.get(3)?,
829        retain_pinned: row.get(4)?,
830        phase,
831        provider_bytes_total,
832        attempt_count: u64::try_from(attempt_count).map_err(|error| invalid(6, Box::new(error)))?,
833        last_failure,
834        created_at: row.get(8)?,
835        last_attempt_at: row.get(9)?,
836    })
837}
838
839fn row_to_published_blob_drop_intent(
840    row: &rusqlite::Row<'_>,
841) -> rusqlite::Result<PublishedBlobDropIntent> {
842    let size: Option<i64> = row.get(3)?;
843    let size = size.ok_or_else(|| {
844        rusqlite::Error::FromSqlConversionFailure(
845            3,
846            rusqlite::types::Type::Integer,
847            Box::new(std::io::Error::new(
848                std::io::ErrorKind::InvalidData,
849                "published blob drop intent is missing size",
850            )),
851        )
852    })?;
853    if size < 0 {
854        return Err(rusqlite::Error::FromSqlConversionFailure(
855            3,
856            rusqlite::types::Type::Integer,
857            Box::new(std::io::Error::new(
858                std::io::ErrorKind::InvalidData,
859                format!("published blob drop intent has negative size {size}"),
860            )),
861        ));
862    }
863    let plaintext_hash = row.get::<_, String>(4)?.parse().map_err(|error| {
864        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(error))
865    })?;
866    let locator_hash = row.get::<_, String>(5)?.parse().map_err(|error| {
867        rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
868    })?;
869    let disposition_raw: String = row.get(6)?;
870    let disposition = coven_protocol::blob::DeferredLocalBlobDisposition::from_db(&disposition_raw)
871        .map_err(|message| {
872            rusqlite::Error::FromSqlConversionFailure(
873                6,
874                rusqlite::types::Type::Text,
875                Box::new(std::io::Error::new(
876                    std::io::ErrorKind::InvalidData,
877                    message,
878                )),
879            )
880        })?;
881    Ok(PublishedBlobDropIntent {
882        seq: row.get::<_, i64>(0)? as u64,
883        drop: coven_protocol::blob::DeferredLocalBlobDrop {
884            namespace: row.get(1)?,
885            id: row.get(2)?,
886            size: size as u64,
887            plaintext_hash,
888            locator_hash,
889            disposition,
890        },
891    })
892}
893
894fn row_to_queued_delete(row: &rusqlite::Row<'_>) -> rusqlite::Result<QueuedDelete> {
895    let invalid = |index: usize, source: Box<dyn std::error::Error + Send + Sync>| {
896        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, source)
897    };
898    let encoded: String = row.get(0)?;
899    let stored: coven_protocol::blob::locator::StoredBlobRef =
900        serde_json::from_str(&encoded).map_err(|error| invalid(0, Box::new(error)))?;
901    let attempt_count: i64 = row.get(1)?;
902    let last_error = row
903        .get::<_, Option<String>>(2)?
904        .map(|encoded| {
905            serde_json::from_str::<OutboxFailure>(&encoded)
906                .map(|failure| failure.message)
907                .map_err(|error| invalid(2, Box::new(error)))
908        })
909        .transpose()?;
910    Ok(QueuedDelete {
911        namespace: stored.locator().namespace().to_string(),
912        blob_id: stored.locator().blob_id().to_string(),
913        attempt_count: u64::try_from(attempt_count).map_err(|error| invalid(1, Box::new(error)))?,
914        last_error,
915        created_at: row.get(3)?,
916        last_attempt_at: row.get(4)?,
917    })
918}