Skip to main content

coven_core/blob/
mod.rs

1//! The blob engine: coven's single owner of a blob's whole durability lifecycle.
2//!
3//! coven syncs opaque encrypted blobs referenced by DB rows. By default it owns
4//! the cloud layout (the content-addressed `{namespace}/{ab}/{cd}/{id}`) and
5//! encryption; the host decides which rows carry blobs, where their plaintext
6//! lives locally, and how each is scoped for encryption. A home configured for
7//! the unobfuscated blob-path scheme instead stores each blob at the consumer's
8//! readable [`BlobRef::cloud_path`] so the bucket is browsable.
9//!
10//! # The coven concept tree
11//!
12//! A blob has two **declared** properties — [`Provenance`] (its Local story) and
13//! [`CacheFill`] (its Remote story) — and one **state**, locality, flipped by the
14//! transitions. The cache is a *mechanism* that serves Remote blobs; it is not a
15//! kind of blob.
16//!
17//! ```text
18//! A blob the host declares with:
19//!
20//! provenance — its LOCAL story: where the bytes live when Local, and the
21//!              Remote→Local path requirement
22//!    ├─ user-provided   the user's file at a path; coven references it.
23//!    │                  Remote→Local writes the bytes back to a user file → NEEDS A PATH.
24//!    └─ host-provided   bae hands coven the data; coven keeps it in its local store.
25//!                       Remote→Local restores it to the local store → no path.
26//!
27//! cache fill — its REMOTE story: how a device gets the bytes when the release is
28//!              Remote. A cache-mechanism setting; applies to ANY blob, regardless of
29//!              provenance, once it is Remote.
30//!    ├─ CacheEager   fetched into the cache on pull, with the SQL row   (covers)
31//!    └─ CacheLazy    fetched into the cache on first read               (audio — big, fetch what you play)
32//!
33//! and a current state:
34//!
35//! locality
36//!    ├─ Local    bytes on-device — the user's path (user-provided) or coven's local store (host-provided)
37//!    └─ Remote   bytes in the cloud; each device's local copy is a CACHE copy, filled
38//!                per `cache fill`, kept-or-evicted per `pin`
39//!
40//! namespace (bucket)   the blob's category — release_files · covers · artist_images
41//!
42//! transitions
43//!    ├─ Local → Remote   upload the bytes; now cache-distributed to every device per cache fill
44//!    └─ Remote → Local   bring the bytes back to a local file — path required iff user-provided
45//!
46//! cache budget   per-NAMESPACE size limit; each namespace evicts independently, so
47//!                evicting release_files (big) never touches covers (small reserved slice)
48//! pin            keep one specific Remote blob's cache copy from eviction (e.g. a
49//!                release the user pinned for offline)
50//! ```
51//!
52//! ## The cache vs local files
53//!
54//! The cache holds local copies of **Remote** blobs (filled per `cache fill`,
55//! evicted per budget unless pinned). It is **segmented by namespace**: each
56//! namespace has its own configurable cache budget and evicts independently, so
57//! evicting `release_files` (big) never touches `covers` (a small reserved slice). A
58//! `CacheEager` cover that falls out of its namespace budget shows a placeholder
59//! until the next read re-fetches it — covers are not pinned. A **Local** blob is not
60//! in the cache: a user-provided Local blob is the user's file at its path (an
61//! external ref); a host-provided Local blob is in coven's local store (see
62//! [`local_files`]). The cache is the mechanism for *remoteness* — so
63//! `CacheEager`/`CacheLazy`/pin/budget describe a blob only while it is Remote, never
64//! while it is Local.
65//!
66//! # The engine's halves
67//!
68//! This module is the engine; its halves move a blob through its lifecycle:
69//!
70//! - [`cache`] — the device-local cache for **Remote** blobs: bytes on disk keyed
71//!   by blob id, with the folder a file lives in as the only retention truth
72//!   (`storage/pinned/` protected, `storage/cache/` evictable). Read (whole and
73//!   ranged), pin/unpin, clear, and budget eviction.
74//! - [`local_files`] — coven's own copy of a **host-provided Local** blob, in the
75//!   local store (`storage/local/<namespace>/<id>`). Never evicted; the budget
76//!   sweep never walks it. Store, read, drop.
77//! - [`upload`] — the cloud-write half: drain the durable upload queue, sealing
78//!   each blob under its scope and writing it to the cloud with coalesced progress,
79//!   so a local-only blob becomes uploaded. The sync cycle calls the drain
80//!   each round before it pushes.
81//! - [`delete`] — the cloud-delete half: turn a queued deletion into a signed
82//!   cloud tombstone, hold the blob for a convergence grace so a lagging peer
83//!   isn't stranded, then GC the blob once the grace has passed. The sync cycle
84//!   drains tombstones and runs the GC each round after it pulls.
85//!
86//! The types below ([`BlobRef`], [`BlobScope`], [`Provenance`],
87//! [`CacheFill`], [`BlobTransitionObserver`]) are the vocabulary both halves and
88//! the host speak. Which rows carry blobs is not a runtime callback but a per-table
89//! declaration ([`crate::sync::session::BlobDecl`]) coven resolves into a
90//! [`decl::BlobDecls`] each cycle to derive the blob set itself.
91//!
92//! coven also owns the two locality transitions ([`transition`]): `make_remote`
93//! (Local → Remote: upload the bytes, then flip the gate) and `make_local`
94//! (Remote → Local: bring each blob back to a local file, then retract). The
95//! make-Remote *completion* — flipping the gate the instant the last user-provided
96//! upload lands — lives in the [`upload`] drain, the one place that knows an upload
97//! just succeeded.
98
99pub mod cache;
100#[cfg(test)]
101mod cache_tests;
102pub mod decl;
103pub mod delete;
104#[doc(hidden)]
105pub mod local_cleanup;
106pub mod local_files;
107pub mod locator;
108pub mod transition;
109pub mod upload;
110
111pub use delete::BLOB_TOMBSTONE_GRACE;
112
113use sha2::{Digest, Sha256};
114
115/// The content hash a blob-bearing row carries: the lowercase-hex SHA-256 of the
116/// blob's plaintext bytes, computed at import and stored in the row's blob columns
117/// alongside the declared size. The row is carried in a signed changeset (and in a
118/// signed snapshot), so this hash is signed by the row's author — that is what
119/// makes it authoritative: on download coven hashes the decrypted plaintext and
120/// requires equality with the row's hash, so the bytes are pinned by the author,
121/// not by the cloud key they happened to arrive under. A host computes this over a
122/// blob's plaintext at import and writes it into the row's declared hash column,
123/// the same way it writes the plaintext length into the size column.
124pub fn content_hash(plaintext: &[u8]) -> String {
125    hex::encode(Sha256::digest(plaintext))
126}
127
128/// An incremental SHA-256 over a blob's plaintext, so the streaming download path
129/// verifies a large blob's content hash without ever holding the whole plaintext
130/// in memory — feed each decrypted chunk to [`update`](Self::update) as it is read,
131/// then [`verify`](Self::verify) against the row's hash before the bytes are
132/// committed to the cache. The hex-encoded digest matches [`content_hash`] over the
133/// same bytes.
134pub struct ContentHasher(Sha256);
135
136impl ContentHasher {
137    pub fn new() -> Self {
138        ContentHasher(Sha256::new())
139    }
140
141    /// Fold the next plaintext chunk into the running digest.
142    pub fn update(&mut self, chunk: &[u8]) {
143        self.0.update(chunk);
144    }
145
146    /// The lowercase-hex digest of everything fed so far.
147    pub fn finish(self) -> String {
148        hex::encode(self.0.finalize())
149    }
150}
151
152impl Default for ContentHasher {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158/// How many blob transfers coven runs at once in each of its two transfer loops:
159/// the upload drain ([`upload::drain_uploads`]) and the pin/download loop
160/// ([`cache::pin`]). An open-time blob-engine tunable the host sets on the builder,
161/// carried on [`Database`](crate::database::Database) alongside the other open-time
162/// blob config and read back by each loop, which holds `&Database`.
163///
164/// Each bound is a [`NonZeroUsize`], so a zero — which would leave a loop admitting
165/// nothing and never completing — is unrepresentable rather than clamped or rejected
166/// at open. `serial()` (both `1`) is the default and reproduces the fully-serial
167/// loops: one transfer at a time, in queue order.
168///
169/// [`NonZeroUsize`]: std::num::NonZeroUsize
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub struct TransferLimits {
172    /// Maximum concurrent blob uploads in one upload-drain pass.
173    pub uploads: std::num::NonZeroUsize,
174    /// Maximum concurrent blob downloads (fetches) in one pin call.
175    pub downloads: std::num::NonZeroUsize,
176}
177
178impl TransferLimits {
179    /// One at a time in each loop — the fully-serial default.
180    pub fn serial() -> Self {
181        Self {
182            uploads: std::num::NonZeroUsize::MIN,
183            downloads: std::num::NonZeroUsize::MIN,
184        }
185    }
186}
187
188// The cache's own tests: real `Database` + `TestStore` over a temp store
189// dir, asserting hits/misses, the pinned/cache folder split, and pin/unpin/clear.
190// These drive a real temp directory on the filesystem. See [`cache`].
191// The upload drain's tests: real `Database` (the `cloud_outbox` queue) driven
192// against `InMemoryCloudHome`/`FailingCloudHome`, asserting record-and-continue,
193// per-entry backoff, scope-resolved sealing, and the observer callbacks. See
194// [`upload`].
195#[cfg(test)]
196mod upload_tests;
197// The coven-owned make-Remote / make-Local transition tests: multi-device
198// make_remote + make_local through the real cycle, cancel both directions, the
199// drain's completion flip, durable cancellation, crash-idempotency at each commit
200// boundary, and a round-trip. Uses a `watch` cancel signal + `run_single_sync_cycle`,
201// See [`transition`].
202#[cfg(test)]
203mod transition_tests;
204// The local-files store's tests: store/read round-trip, a host-provided Local blob
205// surviving a budget sweep (the sweep never walks `local/`), and drop. These
206// drive a real temp directory. See [`local_files`].
207#[cfg(test)]
208mod local_files_tests;
209// The delete half's tests: tombstone signing, the drain that writes tombstones,
210// the graced GC that reclaims exact immutable objects, and the delete-outbox row
211// shape. Driven against `InMemoryCloudHome` and
212// `TestStore`. See [`delete`].
213#[cfg(test)]
214mod delete_tests;
215
216/// Which key encrypts a blob, as a host names it on a [`BlobRef`].
217///
218/// The host names *what* a blob is scoped to — the whole store or a derived
219/// per-scope key — never the raw key bytes. Storage and encryption consume this
220/// same type; there is no key material in it to leak.
221#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222pub enum BlobScope {
223    /// The store master key — every member reads it.
224    Master,
225    /// A per-scope key derived from the master key (e.g. one key per item).
226    Derived(String),
227}
228
229impl BlobScope {
230    /// Serialize for the `cloud_outbox.scope` column. The audio outbox persists
231    /// the scope at enqueue and resolves it to a key at drain, so the string
232    /// must round-trip every variant. The variant tag is split from the payload
233    /// at the first `:`; the payload (a derived scope name) is stored verbatim,
234    /// so it may itself contain `:`.
235    pub fn to_outbox_str(&self) -> String {
236        match self {
237            BlobScope::Master => "master".to_string(),
238            BlobScope::Derived(s) => format!("derived:{s}"),
239        }
240    }
241
242    /// Parse a `cloud_outbox.scope` value written by [`Self::to_outbox_str`].
243    /// Returns `None` on an unknown tag (a corrupt row), which the drain surfaces
244    /// rather than silently defaulting to the master key.
245    pub fn from_outbox_str(s: &str) -> Option<Self> {
246        match s.split_once(':') {
247            None if s == "master" => Some(BlobScope::Master),
248            Some(("derived", rest)) => Some(BlobScope::Derived(rest.to_string())),
249            _ => None,
250        }
251    }
252}
253
254/// A blob's **Local story**: where its bytes live while the blob is Local, and
255/// whether bringing it back from Remote needs a destination path. Orthogonal to
256/// [`CacheFill`] (the Remote story) — a blob declares both.
257///
258/// The cache never enters into this: a Local blob is not a cache copy. Provenance
259/// decides which of the two Local homes holds it, and what `make_local` does to
260/// restore it.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
262pub enum Provenance {
263    /// The user's own file at a path; coven references it but does not own it
264    /// (tracked as an external ref — see `local_blob_refs`). `make_local` writes
265    /// the bytes back to a user file, so it **needs a destination path**.
266    UserProvided,
267    /// The host hands coven the data; coven keeps its own copy in the local store
268    /// (`storage/local/<namespace>/<id>`, see [`local_files`]). `make_local`
269    /// restores it to the local store, so it needs **no path**.
270    HostProvided,
271}
272
273/// A blob's **Remote story**: how a device gets the bytes once the blob is Remote.
274/// A cache-mechanism setting — it describes a blob only while Remote — that applies
275/// to ANY blob regardless of [`Provenance`]. Orthogonal to provenance; a blob
276/// declares both.
277///
278/// Both classes are declared per blob and are global (every device reads the same
279/// class from the blob's [`BlobRef`]); the difference is what a device does with
280/// the blob on pull. The distinction has to be a declared property and not a
281/// per-device choice: device B, deciding during its own pull whether to fetch a
282/// blob, can only read the blob's declared class — it cannot see what device A
283/// chose locally.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
285pub enum CacheFill {
286    /// Fetched into the cache on pull, right away, on every device — part of
287    /// "having the store" (e.g. cover art, so the grid renders from local bytes
288    /// without a fetch). The cache copy is evictable + re-fetchable, not pinned.
289    CacheEager,
290    /// Not fetched on pull: a pulling device skips it and fetches it into the cache
291    /// on first read — e.g. audio, which is big and streams on demand.
292    CacheLazy,
293}
294
295/// A blob's **replacement story**: whether the row carrying it may ever be repointed at
296/// a different blob. Orthogonal to [`Provenance`] and [`CacheFill`]; a blob declares all
297/// three.
298///
299/// It exists because a cloud object must never be rewritten with different bytes. The
300/// pull verifies an object against its row's content hash and a position advances only over
301/// a fully-realized changeset, so a key whose content can change leaves a device that
302/// pulls an older changeset unable to satisfy it — wedged there for good, not merely
303/// missing a blob. Two declarations reach that guarantee by different routes, and coven
304/// enforces whichever one the blob declares:
305///
306/// - [`Replaceable`](Self::Replaceable) — the row may be repointed, so the *key* must
307///   move with the blob: a readable `cloud_path` has to name its blob
308///   ([`crate::blob::decl::cloud_path_names_blob`]), and a replacement then writes a new
309///   object beside the one it replaces instead of over it.
310/// - [`WriteOnce`](Self::WriteOnce) — the row is never repointed, so the object at its
311///   key is written once and there is nothing to protect it from. Its path is free to be
312///   a stable, fully readable name. coven refuses the repointing.
313///
314/// `Replaceable` is the default: its guarantee is the airtight one (the key itself
315/// carries the blob id, so no path can ever be reused), while `WriteOnce` is a weaker
316/// contract a consumer opts into knowingly — see its docs.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum BlobReplacement {
319    /// The row may be repointed at a new blob id — replacing a cover, swapping an
320    /// attachment. Requires a readable `cloud_path` that names its blob, so that the
321    /// replacement's fresh blob id yields a fresh key.
322    Replaceable,
323    /// The row is never repointed: the blob it names when it is inserted is the blob it
324    /// names for life. Repointing one is refused.
325    ///
326    /// This buys a stable, fully readable cloud path — `Live at Leeds/01 Sonata.flac`
327    /// rather than `01 Sonata-0ef7a1c9.flac` — for content that is written once and never
328    /// rewritten: an imported file, whose bytes are what they are.
329    ///
330    /// **What coven enforces, and what it does not.** coven refuses to repoint the row,
331    /// which is the reuse it can see. It cannot see a consumer *deleting* a row and
332    /// inserting a different blob at the same `cloud_path` — the deleted row is gone, and
333    /// coven keeps no history of the paths it has used. Declaring `WriteOnce` is therefore
334    /// also a promise that the path is never reused by a different blob. Derive it from
335    /// data that never repeats and it holds by construction: a path carrying a freshly
336    /// minted id for the thing being imported can never be handed out twice.
337    WriteOnce,
338}
339
340/// A blob a row references: its cloud identity, encryption scope, and the two
341/// declared properties ([`provenance`](BlobRef::provenance) +
342/// [`fill`](BlobRef::fill)). coven derives it from the row's declared columns
343/// ([`crate::sync::session::BlobDecl`]) via [`decl::BlobDecls`]. Where its bytes
344/// live depends on its locality and provenance: a user-provided Local blob is the
345/// user's file at its path; a host-provided Local blob is in coven's local store
346/// (`storage/local/<namespace>/<id>`); a Remote blob's device-local copy is a cache
347/// copy (`storage/pinned/<namespace>/<id>` / `storage/cache/<namespace>/<id>`, built
348/// from the validated namespace + id — see [`cache`]).
349#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
350pub struct BlobRef {
351    /// Cloud namespace, e.g. `"images"`. Becomes `{namespace}/{ab}/{cd}/{id}`
352    /// under the hashed scheme, or `{namespace}/{cloud_path}` under the plain one.
353    pub namespace: String,
354    /// Blob id (typically the id of the blob-bearing row).
355    pub id: String,
356    /// Encryption scope for this blob.
357    pub scope: BlobScope,
358    /// The consumer's readable cloud-relative path for this blob, e.g.
359    /// `"Artist - Album/cover.jpg"`. Used as the object key under `namespace` when
360    /// the home's [`crate::sync::cloud_storage::BlobPathScheme`] is `Plain`;
361    /// ignored when `Hashed`. `None` is only valid for a `Hashed` home — a `Plain`
362    /// home with no `cloud_path` is a surfaced error, never a silent fallback.
363    pub cloud_path: Option<String>,
364    /// The blob's **Local story**: where its bytes live while Local, and whether
365    /// `make_local` needs a destination path. See [`Provenance`].
366    pub provenance: Provenance,
367    /// The blob's **Remote story**: whether a pulling device fetches it into the
368    /// cache right away ([`CacheFill::CacheEager`]) or on first read
369    /// ([`CacheFill::CacheLazy`]). See [`CacheFill`].
370    pub fill: CacheFill,
371}
372
373/// One exact blob-bearing row version. A reference becomes stale when the live
374/// row stamp or any declared blob value changes.
375#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
376#[serde(deny_unknown_fields)]
377pub struct RowBlobRef {
378    table: String,
379    row_id: String,
380    row_stamp: String,
381    column: String,
382    blob: BlobRef,
383    plaintext_size: u64,
384    plaintext_hash: crate::sync::store_commit::ObjectHash,
385    authority: RowBlobAuthority,
386    stored: Option<locator::StoredBlobRef>,
387}
388
389/// The authority state that determines where one row version's blob lives.
390/// A remote-audience blob remains `PendingRemote` while its verified plaintext
391/// is local and no cloud object has been created; `Remote` carries the exact
392/// package authority needed to open its committed object.
393#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
394#[serde(rename_all = "snake_case", deny_unknown_fields)]
395pub enum RowBlobAuthority {
396    Local,
397    PendingRemote(locator::RemoteAudience),
398    Remote(crate::sync::audience_package::PackageAudience),
399}
400
401impl<'de> serde::Deserialize<'de> for RowBlobRef {
402    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
403    where
404        D: serde::Deserializer<'de>,
405    {
406        #[derive(serde::Deserialize)]
407        #[serde(deny_unknown_fields)]
408        struct Fields {
409            table: String,
410            row_id: String,
411            row_stamp: String,
412            column: String,
413            blob: BlobRef,
414            plaintext_size: u64,
415            plaintext_hash: crate::sync::store_commit::ObjectHash,
416            authority: RowBlobAuthority,
417            stored: Option<locator::StoredBlobRef>,
418        }
419
420        let fields = Fields::deserialize(deserializer)?;
421        Self::new(
422            fields.table,
423            fields.row_id,
424            fields.row_stamp,
425            fields.column,
426            fields.blob,
427            fields.plaintext_size,
428            fields.plaintext_hash,
429            fields.authority,
430            fields.stored,
431        )
432        .map_err(serde::de::Error::custom)
433    }
434}
435
436impl RowBlobAuthority {
437    pub fn audience(&self) -> crate::sync::circle::Audience {
438        match self {
439            Self::Local => crate::sync::circle::Audience::Local,
440            Self::PendingRemote(locator::RemoteAudience::Store) => {
441                crate::sync::circle::Audience::Store
442            }
443            Self::PendingRemote(locator::RemoteAudience::Circle(circle_id)) => {
444                crate::sync::circle::Audience::Circle(*circle_id)
445            }
446            Self::Remote(crate::sync::audience_package::PackageAudience::Store) => {
447                crate::sync::circle::Audience::Store
448            }
449            Self::Remote(crate::sync::audience_package::PackageAudience::Circle {
450                circle_id,
451                ..
452            }) => crate::sync::circle::Audience::Circle(*circle_id),
453        }
454    }
455}
456
457impl RowBlobRef {
458    #[allow(clippy::too_many_arguments)]
459    pub(crate) fn new(
460        table: String,
461        row_id: String,
462        row_stamp: String,
463        column: String,
464        blob: BlobRef,
465        plaintext_size: u64,
466        plaintext_hash: crate::sync::store_commit::ObjectHash,
467        authority: RowBlobAuthority,
468        stored: Option<locator::StoredBlobRef>,
469    ) -> Result<Self, String> {
470        let remote = match &authority {
471            RowBlobAuthority::Local => None,
472            RowBlobAuthority::PendingRemote(audience) => Some(audience.clone()),
473            RowBlobAuthority::Remote(package) => Some(package.remote_audience()),
474        };
475        match (&authority, remote.as_ref(), stored.as_ref()) {
476            (RowBlobAuthority::Local, None, None)
477            | (RowBlobAuthority::PendingRemote(_), Some(_), None) => {}
478            (RowBlobAuthority::Remote(_), Some(expected), Some(stored))
479                if &stored.locator().audience() == expected => {}
480            (RowBlobAuthority::Local, None, Some(_)) => {
481                return Err("Local row blob carries a remote locator".to_string());
482            }
483            (RowBlobAuthority::PendingRemote(_), Some(_), Some(_)) => {
484                return Err("pending remote row blob carries a cloud locator".to_string());
485            }
486            (RowBlobAuthority::Remote(_), Some(_), None) => {
487                return Err("remote row blob has no exact locator".to_string());
488            }
489            (RowBlobAuthority::Remote(_), Some(expected), Some(stored)) => {
490                return Err(format!(
491                    "row audience {expected:?} differs from locator audience {:?}",
492                    stored.locator().audience()
493                ));
494            }
495            _ => unreachable!("authority determines whether a remote audience exists"),
496        }
497        Ok(Self {
498            table,
499            row_id,
500            row_stamp,
501            column,
502            blob,
503            plaintext_size,
504            plaintext_hash,
505            authority,
506            stored,
507        })
508    }
509
510    pub fn table(&self) -> &str {
511        &self.table
512    }
513
514    pub fn row_id(&self) -> &str {
515        &self.row_id
516    }
517
518    pub fn row_stamp(&self) -> &str {
519        &self.row_stamp
520    }
521
522    pub fn column(&self) -> &str {
523        &self.column
524    }
525
526    pub fn blob(&self) -> &BlobRef {
527        &self.blob
528    }
529
530    pub fn plaintext_size(&self) -> u64 {
531        self.plaintext_size
532    }
533
534    pub fn plaintext_hash(&self) -> crate::sync::store_commit::ObjectHash {
535        self.plaintext_hash
536    }
537
538    pub fn authority(&self) -> &RowBlobAuthority {
539        &self.authority
540    }
541
542    pub fn audience(&self) -> crate::sync::circle::Audience {
543        self.authority.audience()
544    }
545
546    pub fn stored(&self) -> Option<&locator::StoredBlobRef> {
547        self.stored.as_ref()
548    }
549}
550
551/// Notified about coven's blob transitions, for host-specific bookkeeping and UI:
552/// per-blob upload progress while a make_remote uploads, per-blob materialize
553/// progress while a make_local copies files back, and a completion hook per
554/// direction the host turns into its own UI event.
555///
556/// The host no longer drives the transition — coven owns flipping the gate and
557/// deciding when a cycle publishes — so this observer only *reports*. The upload
558/// callbacks fire as the drain works: `on_blob_upload_started` before each
559/// attempt, `on_blob_upload_progress` zero or more times as encrypted bytes reach
560/// the cloud (backends that can't report sub-file progress call it once at the end
561/// with `bytes_done == bytes_total`), `on_blob_uploaded` on success (notification
562/// only — coven, not the host, flips the gate and breaks the drain to publish),
563/// and `on_blob_upload_failed` when an attempt fails and its entry stays queued.
564///
565/// `on_root_made_remote` / `on_root_made_local` fire whenever coven *completes* a
566/// transition — including one resumed after a restart — so the host's own
567/// row-updated event survives a restart rather than being lost with an in-memory
568/// flag. `on_blob_materialize_progress` moves a make_local's per-file progress bar.
569///
570/// `should_skip_uploads` lets the host pause the upload pipeline without touching
571/// the queue: the sync cycle consults it before draining so a paused queue still
572/// accepts new entries but doesn't drain ([`upload::drain_uploads`] checks once at
573/// the top of each entry; in-flight uploads complete normally).
574///
575#[async_trait::async_trait]
576pub trait BlobTransitionObserver: Send + Sync {
577    /// An upload attempt for this blob is starting now.
578    async fn on_blob_upload_started(&self, blob_id: &str);
579
580    /// `bytes_done` of `bytes_total` encrypted bytes have reached the cloud for
581    /// this in-flight blob. `bytes_done` is cumulative and monotonic within one
582    /// upload attempt. The default is a no-op so observers that don't surface
583    /// sub-file progress don't need a stub.
584    async fn on_blob_upload_progress(&self, blob_id: &str, bytes_done: u64, bytes_total: u64) {
585        let _ = (blob_id, bytes_done, bytes_total);
586    }
587
588    /// The blob was uploaded to the cloud successfully — notification only. coven
589    /// owns flipping the gate and breaking the drain to publish a completed
590    /// make_remote.
591    async fn on_blob_uploaded(&self, blob_id: &str);
592
593    /// An upload attempt failed; the entry remains queued for retry.
594    async fn on_blob_upload_failed(&self, blob_id: &str, error: &str);
595
596    /// If true, the sync cycle skips the upload drain this round and
597    /// [`upload::drain_uploads`] short-circuits before pulling the next queued
598    /// entry. The default is `false` so existing implementations don't need a stub.
599    fn should_skip_uploads(&self) -> bool {
600        false
601    }
602
603    /// coven completed a make_remote of `(root_table, root_id)`: every blob is
604    /// uploaded and the gate is flipped true (the subtree publishes this cycle).
605    /// Fires for a restart-resumed completion too, so the host's row-updated event
606    /// is not lost to a crash. The default is a no-op.
607    async fn on_root_made_remote(&self, root_table: &str, root_id: &str) {
608        let _ = (root_table, root_id);
609    }
610
611    /// coven completed a make_local of `(root_table, root_id)`: every blob is back
612    /// to a local file (a user file for user-provided, the local store for
613    /// host-provided), the gate is flipped false (the subtree retracts from peers),
614    /// and the cloud blobs are queued for tombstoning. The default is a no-op.
615    async fn on_root_made_local(&self, root_table: &str, root_id: &str) {
616        let _ = (root_table, root_id);
617    }
618
619    /// `done` of `total` of a make_local's blobs have been materialized back to a
620    /// local file, so the host can move a per-file progress bar. The default is a
621    /// no-op.
622    async fn on_blob_materialize_progress(
623        &self,
624        root_table: &str,
625        root_id: &str,
626        blob_id: &str,
627        done: u64,
628        total: u64,
629    ) {
630        let _ = (root_table, root_id, blob_id, done, total);
631    }
632}