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}