Skip to main content

coven_core/blob/
transition.rs

1//! coven-owned locality transitions: `make_remote` (Local → Remote) and
2//! `make_local` (Remote → Local), plus `cancel_make_remote`.
3//!
4//! A blob-bearing root is **Local** (its blobs live on-device — a user-provided
5//! blob at the user's path, a host-provided blob in coven's local store — and the
6//! root's gate is off) or **Remote** (its blobs live in the cloud fronted by
7//! coven's cache, and the gate is on). coven owns moving between the two, with the
8//! durable-copy-before-delete ordering and a single atomic commit point each way:
9//!
10//! - [`make_remote`] verifies and journals every blob-bearing row beneath the root,
11//!   then returns. The upload drain ([`crate::blob::upload::drain_uploads`]) creates
12//!   each exact cloud object. Once every row has a created object, one Store write
13//!   flips the gate true and drops external-file ownership. The intent and exact
14//!   upload journals remain authoritative until that Store write activates, when
15//!   activation consumes them atomically. Before publication starts,
16//!   [`cancel_make_remote`] marks the intent Cancelling; the upload drain
17//!   exact-deletes every object and spool before atomically removing the last
18//!   journal and intent, so the root remains Local.
19//! - [`make_local`] brings each blob back to a local file durability-first
20//!   (read from cache/cloud → write the local copy → verify): a **user-provided**
21//!   blob to its `dest` path (path required) registered as an external ref, a
22//!   **host-provided** blob to coven's local store (no path). Then it takes the
23//!   single commit `{flip the gate false + register the external refs + enqueue the
24//!   cloud deletes}`. The gate retract removes the subtree from peers; the tombstone
25//!   drain reclaims the cloud blobs after the grace.
26//!
27//! Both transitions operate on every exact blob-bearing row under the root and
28//! branch on provenance only to choose its Local filesystem home.
29//!
30//! A [`SyncedTable::remote_root`](crate::sync::session::SyncedTable::remote_root)
31//! has no Local state in this model: its rows sync normally and its blobs are
32//! Remote by construction, so these transition APIs reject it.
33//!
34//! Every destructive step is enqueued durably *inside* the one commit, and nothing
35//! destructive happens before it, so there is no representable half-state and retry
36//! after a crash is idempotent.
37//!
38//! See the [blob concept tree](crate::blob) for how Local/Remote, provenance, and
39//! the cache fit together.
40
41use std::collections::HashMap;
42
43use crate::blob::{Provenance, RowBlobRef};
44use crate::database::{Database, DbError, MakeRemoteIntentState};
45use crate::db::{OutboxEntry, OutboxOperation, OutboxUploadState};
46use crate::store_dir::StoreDir;
47use crate::sync::hlc::Hlc;
48use crate::sync::session::SyncedTable;
49use crate::sync::store::StoreDatabase;
50
51use crate::blob::BlobTransitionObserver;
52use crate::sync::storage::SyncStorage;
53use std::path::PathBuf;
54use tokio::sync::watch;
55
56/// Why a make_remote (or its cancel) could not be started.
57#[derive(Debug, thiserror::Error)]
58pub enum MakeRemoteError {
59    #[error("sync is not running, so a transition cannot start")]
60    SyncNotReady,
61    #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
62    NotGated(String),
63    #[error("table {0:?} is a remote root, so its blobs are already Remote")]
64    RemoteRoot(String),
65    #[error("root {0:?}/{1:?} is already Remote, so make_remote has nothing to do")]
66    AlreadyRemote(String, String),
67    #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
68    UnresolvedLocality(String, String),
69    #[error("nothing to make Remote: root {0:?}/{1:?} has no blobs")]
70    NothingToMakeRemote(String, String),
71    #[error("blob {0:?} is not a user-provided (external) file, so it cannot be made Remote")]
72    NotExternal(String),
73    #[error("external source for blob {blob_id:?} at {path}: {detail}")]
74    Source {
75        blob_id: String,
76        path: String,
77        detail: String,
78    },
79    #[error("database error: {0}")]
80    Db(#[from] DbError),
81}
82
83/// Why a make_local could not complete.
84#[derive(Debug, thiserror::Error)]
85pub enum MakeLocalError {
86    #[error("sync is not running, so a transition cannot start")]
87    SyncNotReady,
88    #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
89    NotGated(String),
90    #[error("table {0:?} is a remote root, so its blobs have no Local state")]
91    RemoteRoot(String),
92    #[error("root {0:?}/{1:?} is already Local, so make_local has nothing to do")]
93    AlreadyLocal(String, String),
94    #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
95    UnresolvedLocality(String, String),
96    #[error("no destination path supplied for user-provided blob {0:?}")]
97    MissingDest(String),
98    #[error("destination path for user-provided blob {blob_id:?} is not valid UTF-8: {path}")]
99    NonUtf8Dest { blob_id: String, path: String },
100    #[error("read blob {0:?} to materialize: {1}")]
101    Read(String, String),
102    #[error("write materialized blob {blob_id:?} to {path}: {detail}")]
103    Write {
104        blob_id: String,
105        path: String,
106        detail: String,
107    },
108    #[error("make_local cancelled before the commit; the release stays Remote")]
109    Cancelled,
110    #[error("could not roll back materialized file at {path}: {detail}")]
111    Cleanup { path: String, detail: String },
112    #[error("database error: {0}")]
113    Db(#[from] DbError),
114}
115
116/// The gate column of `root_table`, or `None` if it is not a gated root.
117fn gate_column<'a>(tables: &'a [SyncedTable], root_table: &str) -> Option<&'a str> {
118    tables
119        .iter()
120        .find(|t| t.name() == root_table)
121        .and_then(|t| t.gate_column())
122}
123
124fn is_remote_root(tables: &[SyncedTable], root_table: &str) -> bool {
125    tables
126        .iter()
127        .any(|t| t.name() == root_table && t.is_remote_root())
128}
129
130/// Validate that `root_table` is a coven-owned gated root (rejecting a remote root
131/// and a non-gated table) and return its gate column. The gate column names the row
132/// whose truth is the root's Local/Remote state — [`make_remote`] reads it to refuse
133/// a root already Remote.
134fn gated_root_gate_col(
135    tables: &[SyncedTable],
136    root_table: &str,
137) -> Result<String, MakeRemoteError> {
138    if is_remote_root(tables, root_table) {
139        return Err(MakeRemoteError::RemoteRoot(root_table.to_string()));
140    }
141    gate_column(tables, root_table)
142        .map(str::to_string)
143        .ok_or_else(|| MakeRemoteError::NotGated(root_table.to_string()))
144}
145
146/// Start making `(root_table, root_id)` Remote: refuse a root already Remote, then
147/// verify each user-provided blob's external source file, enqueue an upload per blob,
148/// and record the make_remote intent in one transaction. Returns once enqueued — the
149/// sync cycle uploads all needed blobs and flips the gate true only after they land.
150/// The caller triggers a sync cycle to start that completion.
151///
152/// Verifying every source up front (exists + length matches the registered size)
153/// means a missing file aborts with nothing enqueued, rather than leaving a
154/// half-queued make_remote. `pin` becomes each upload's `retain_pinned`, so the
155/// blob is kept in coven's cache as a pinned (offline) copy.
156pub async fn make_remote(
157    database: &StoreDatabase,
158    store_dir: &StoreDir,
159    hlc: &Hlc,
160    root_table: &str,
161    root_id: &str,
162    pin: bool,
163) -> Result<(), MakeRemoteError> {
164    let db = database.sqlite();
165    let tables = db.synced_tables().to_vec();
166    let gate_col = gated_root_gate_col(&tables, root_table)?;
167    let (locality_table, locality_column, locality_id) = (
168        root_table.to_string(),
169        gate_col.clone(),
170        root_id.to_string(),
171    );
172    let locality = db
173        .call(move |conn| {
174            crate::sync::gate::query_truth(conn, &locality_table, &locality_column, &locality_id)
175                .map_err(|error| DbError::Message(error.to_string()))
176        })
177        .await?;
178    match locality {
179        Some(false) => {}
180        Some(true) => {
181            return Err(MakeRemoteError::AlreadyRemote(
182                root_table.to_string(),
183                root_id.to_string(),
184            ));
185        }
186        None => {
187            return Err(MakeRemoteError::UnresolvedLocality(
188                root_table.to_string(),
189                root_id.to_string(),
190            ));
191        }
192    }
193
194    let refs = db.row_blob_refs_for_root(root_table, root_id).await?;
195    if refs.is_empty() {
196        return Err(MakeRemoteError::NothingToMakeRemote(
197            root_table.to_string(),
198            root_id.to_string(),
199        ));
200    }
201
202    let mut uploads = Vec::with_capacity(refs.len());
203    for reference in refs {
204        if reference.authority() != &crate::blob::RowBlobAuthority::Local
205            || reference.stored().is_some()
206        {
207            return Err(MakeRemoteError::AlreadyRemote(
208                root_table.to_string(),
209                root_id.to_string(),
210            ));
211        }
212        let blob = reference.blob();
213        let source_path = match blob.provenance {
214            Provenance::UserProvided => {
215                db.external_blob_for_row(&reference)
216                    .await?
217                    .ok_or_else(|| MakeRemoteError::NotExternal(blob.id.clone()))?
218                    .path
219            }
220            Provenance::HostProvided => store_dir
221                .local_blob_path(&blob.namespace, &blob.id)
222                .map_err(|error| MakeRemoteError::Source {
223                    blob_id: blob.id.clone(),
224                    path: format!("local/{}/{}", blob.namespace, blob.id),
225                    detail: error.to_string(),
226                })?,
227        };
228        let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(&source_path)
229            .await
230            .map_err(|detail| MakeRemoteError::Source {
231                blob_id: blob.id.clone(),
232                path: source_path.display().to_string(),
233                detail,
234            })?;
235        if actual_size != reference.plaintext_size() || actual_hash != reference.plaintext_hash() {
236            return Err(MakeRemoteError::Source {
237                blob_id: blob.id.clone(),
238                path: source_path.display().to_string(),
239                detail: format!(
240                    "plaintext facts {actual_size}/{actual_hash} differ from row facts {}/{}",
241                    reference.plaintext_size(),
242                    reference.plaintext_hash(),
243                ),
244            });
245        }
246        uploads.push((reference, source_path));
247    }
248
249    let created_at = hlc.now().to_string();
250    let (rt, gc, ri) = (
251        root_table.to_string(),
252        gate_col.clone(),
253        root_id.to_string(),
254    );
255    let gates = db.gates();
256    let locality = db
257        .call(move |conn| {
258            let tx = conn.unchecked_transaction()?;
259            let locality = crate::sync::gate::query_truth(&tx, &rt, &gc, &ri)
260                .map_err(|e| DbError::Message(e.to_string()))?;
261            if locality == Some(false) {
262                let current = Database::row_blob_refs_for_root_on(
263                    &tx, &gates, &tables, &rt, &ri,
264                )?;
265                if current.len() != uploads.len()
266                    || current
267                        .iter()
268                        .zip(&uploads)
269                        .any(|(current, (verified, _))| current != verified)
270                {
271                    return Err(DbError::Message(format!(
272                        "blob rows below {rt:?}/{ri:?} changed while make_remote verified their sources"
273                    )));
274                }
275                Database::insert_make_remote_intent_on(&tx, &rt, &ri, pin)?;
276                for (reference, source_path) in &uploads {
277                    Database::enqueue_upload_on(
278                        &tx,
279                        &rt,
280                        &ri,
281                        reference,
282                        source_path,
283                        pin,
284                        &created_at,
285                    )?;
286                }
287                tx.commit().map_err(DbError::from)?;
288            }
289            Ok(locality)
290        })
291        .await?;
292    match locality {
293        Some(false) => Ok(()),
294        Some(true) => Err(MakeRemoteError::AlreadyRemote(
295            root_table.to_string(),
296            root_id.to_string(),
297        )),
298        None => Err(MakeRemoteError::UnresolvedLocality(
299            root_table.to_string(),
300            root_id.to_string(),
301        )),
302    }
303}
304
305/// Cancel an uploading make_remote by durably marking its intent Cancelling. The gate
306/// remains Local. The upload drain exact-deletes each prepared/created cloud object
307/// and spool, then removes the last journal and intent together. A new transition
308/// cannot start over cleanup in progress. Once the publication Store write exists,
309/// cancellation is rejected because that write owns the transition's atomic outcome.
310pub async fn cancel_make_remote(
311    db: &Database,
312    root_table: &str,
313    root_id: &str,
314) -> Result<(), MakeRemoteError> {
315    let tables = db.synced_tables();
316    gated_root_gate_col(tables, root_table)?;
317    let (root_table_owned, root_id_owned) = (root_table.to_string(), root_id.to_string());
318    db.call(move |conn| {
319        let tx = conn.unchecked_transaction()?;
320        match Database::make_remote_intent_state(&tx, &root_table_owned, &root_id_owned)? {
321            Some(MakeRemoteIntentState::Uploading) => {
322                Database::mark_make_remote_cancelling_on(
323                    &tx,
324                    &root_table_owned,
325                    &root_id_owned,
326                )?;
327            }
328            Some(MakeRemoteIntentState::Cancelling) => {}
329            Some(MakeRemoteIntentState::Publishing(write_id)) => {
330                return Err(DbError::Message(format!(
331                    "make_remote for {root_table_owned:?}/{root_id_owned:?} is already publishing as {write_id}"
332                )));
333            }
334            None => {
335                return Err(DbError::Message(format!(
336                    "make_remote for {root_table_owned:?}/{root_id_owned:?} does not exist"
337                )));
338            }
339        }
340        tx.commit().map_err(DbError::from)
341    })
342    .await
343    .map_err(MakeRemoteError::from)
344}
345
346pub(crate) enum PostUpload {
347    Waiting,
348    Cancelled,
349    MadeRemote { root_table: String, root_id: String },
350}
351
352/// Complete a Created upload journal entry without exposing a Remote row that lacks
353/// exact object authority. Every blob-bearing row below the same gated root must
354/// still match its queued row version and have reached Created. The final transaction
355/// then flips the gate, clears external-file ownership, records the pending Store
356/// write, and binds the transition intent to that write together. The intent and
357/// Created handoffs remain until that Store write activates, so a crash cannot make
358/// the upload drain mistake a published object for an orphan.
359pub(crate) async fn finalize_created_upload(
360    database: &StoreDatabase,
361    entry: &OutboxEntry,
362    stamp: String,
363    routing_encryption: Option<crate::encryption::EncryptionService>,
364) -> Result<PostUpload, DbError> {
365    let db = database.sqlite();
366    let OutboxOperation::Upload {
367        root_table,
368        root_id,
369        row,
370        state,
371        ..
372    } = &entry.operation
373    else {
374        return Err(DbError::Message(
375            "make_remote finalizer received a non-upload outbox entry".to_string(),
376        ));
377    };
378    if !matches!(state, OutboxUploadState::Created { .. }) {
379        return Err(DbError::Message(
380            "make_remote finalizer requires a Created exact upload".to_string(),
381        ));
382    }
383
384    let entry = entry.clone();
385    let root_table = root_table.clone();
386    let root_id = root_id.clone();
387    let row = row.clone();
388    let tables = db.synced_tables().to_vec();
389    let gates = db.gates();
390    let write_id = db.new_write_id();
391    db.call(move |conn| {
392        let resolved_root = gates
393            .resolve_root_of(conn, row.table(), row.row_id())
394            .map_err(|error| DbError::Message(error.to_string()))?
395            .ok_or_else(|| {
396                DbError::Message(format!(
397                    "upload row {:?}/{:?} has no gated transition root",
398                    row.table(),
399                    row.row_id()
400                ))
401            })?;
402        if resolved_root != (root_table.clone(), root_id.clone()) {
403            return Err(DbError::Message(format!(
404                "upload row {:?}/{:?} moved from make_remote root {:?}/{:?} to {:?}/{:?}",
405                row.table(),
406                row.row_id(),
407                root_table,
408                root_id,
409                resolved_root.0,
410                resolved_root.1
411            )));
412        }
413        match Database::make_remote_intent_state(conn, &root_table, &root_id)? {
414            Some(MakeRemoteIntentState::Uploading) => {}
415            Some(MakeRemoteIntentState::Publishing(_)) => return Ok(PostUpload::Waiting),
416            Some(MakeRemoteIntentState::Cancelling) => {
417                return Ok(PostUpload::Cancelled);
418            }
419            None => {
420                return Err(DbError::Message(format!(
421                    "upload for {root_table:?}/{root_id:?} has no make_remote intent"
422                )));
423            }
424        }
425
426        let rows = Database::row_blob_refs_for_root_on(
427            conn,
428            &gates,
429            &tables,
430            &root_table,
431            &root_id,
432        )?;
433        let entries = Database::upload_entries_for_root_on(
434            conn,
435            &gates,
436            &tables,
437            &root_table,
438            &root_id,
439        )?;
440        if rows.len() != entries.len() {
441            return Err(DbError::Message(format!(
442                "make_remote root {root_table:?}/{root_id:?} has {} blob rows but {} exact upload journals",
443                rows.len(),
444                entries.len()
445            )));
446        }
447        if !entries.iter().all(|candidate| {
448            matches!(
449                candidate.operation,
450                OutboxOperation::Upload {
451                    state: OutboxUploadState::Created { .. },
452                    ..
453                }
454            )
455        }) {
456            return Ok(PostUpload::Waiting);
457        }
458        if !entries.iter().any(|candidate| candidate == &entry) {
459            return Err(DbError::Message(
460                "Created upload changed before make_remote finalization".to_string(),
461            ));
462        }
463
464        let gate_column = gate_column(&tables, &root_table).ok_or_else(|| {
465            DbError::Message(format!(
466                "make_remote root {root_table:?} has no boolean gate column"
467            ))
468        })?;
469        let publication_write_id = write_id.clone();
470        StoreDatabase::run_prepared_blob_transition_transaction_on(
471            conn,
472            &tables,
473            routing_encryption.as_ref(),
474            write_id,
475            |tx| {
476                crate::sync::gate::write_gate(
477                    tx,
478                    &root_table,
479                    gate_column,
480                    true,
481                    &stamp,
482                    &root_id,
483                )
484                .map_err(DbError::from)?;
485                for reference in &rows {
486                    if reference.blob().provenance == Provenance::UserProvided {
487                        Database::clear_external_blob_on(tx, reference)?;
488                    }
489                }
490                Database::mark_make_remote_publishing_on(
491                    tx,
492                    &root_table,
493                    &root_id,
494                    &publication_write_id,
495                )
496            },
497        )?;
498        Ok(PostUpload::MadeRemote {
499            root_table,
500            root_id,
501        })
502    })
503    .await
504}
505
506// ===========================================================================
507// make_local — foreground operation with a cancel signal
508// ===========================================================================
509
510/// One blob materialized back to a local file by [`make_local`], carrying what the
511/// single commit needs. `dest` is present for a user-provided blob whose local
512/// home is the user's path; absent for a host-provided blob whose local home is
513/// coven's local store.
514#[derive(Clone)]
515struct Materialized {
516    remote: RowBlobRef,
517    stored: crate::blob::locator::StoredBlobRef,
518    dest: Option<PathBuf>,
519}
520
521/// Bring a Remote root's blobs back to local files, then flip it Local in one atomic
522/// commit. Foreground and awaitable, with per-blob materialize progress and
523/// cooperative cancellation.
524///
525/// Durability-first, exactly the ordering a Remote→Local transition needs: for each
526/// blob, read it (cache or cloud) and write its local copy — a **user-provided**
527/// blob to `dest[blob_id]` (path required), a **host-provided** blob to coven's
528/// local store (no path) — each via temp + rename + fsync (file and directory) +
529/// length verify, emitting progress. Only after ALL blobs are durable does the
530/// single commit run: flip the gate false, register each user-provided file as an
531/// external ref, and enqueue each cloud blob's delete — together, so a crash can't
532/// flip the root Local while leaving the cloud blobs un-tombstoned. The gate retract
533/// removes the subtree from peers next push.
534///
535/// `dest` carries user-provided ids only; a missing host-provided dest is not an
536/// error. Cancellation, or any failure (a missing user-provided dest, a read error,
537/// a write error) before the commit, deletes the partial local copies already
538/// written and aborts: the gate is still on and the cloud is intact, so the root
539/// stays Remote and a retry re-materializes cleanly.
540#[allow(clippy::too_many_arguments)]
541pub async fn make_local(
542    database: &StoreDatabase,
543    storage: &dyn SyncStorage,
544    store_dir: &StoreDir,
545    hlc: &Hlc,
546    routing_encryption: Option<crate::encryption::EncryptionService>,
547    observer: Option<&dyn BlobTransitionObserver>,
548    root_table: &str,
549    root_id: &str,
550    dest: &HashMap<String, PathBuf>,
551    cancel: &watch::Receiver<bool>,
552) -> Result<(), MakeLocalError> {
553    let db = database.sqlite();
554    let tables = db.synced_tables().to_vec();
555    StoreDatabase::validate_store_write_routing(db.gates().as_ref(), routing_encryption.as_ref())?;
556    if is_remote_root(&tables, root_table) {
557        return Err(MakeLocalError::RemoteRoot(root_table.to_string()));
558    }
559    let gate_col = gate_column(&tables, root_table)
560        .ok_or_else(|| MakeLocalError::NotGated(root_table.to_string()))?
561        .to_string();
562
563    // Refuse a root already Local before any materialization. Otherwise the
564    // materializer would try to read each blob back from the cloud — a Local blob has
565    // no cloud copy — and fail deep inside with a misleading cloud-read error.
566    // `query_truth` is the same locality reader the read/delete paths use, so there is
567    // one definition of a root's Local/Remote state.
568    let (rt, gc, ri) = (
569        root_table.to_string(),
570        gate_col.clone(),
571        root_id.to_string(),
572    );
573    let locality = db
574        .call(move |conn| {
575            crate::sync::gate::query_truth(conn, &rt, &gc, &ri)
576                .map_err(|e| DbError::Message(e.to_string()))
577        })
578        .await?;
579    match locality {
580        Some(true) => {}
581        Some(false) => {
582            return Err(MakeLocalError::AlreadyLocal(
583                root_table.to_string(),
584                root_id.to_string(),
585            ))
586        }
587        None => {
588            return Err(MakeLocalError::UnresolvedLocality(
589                root_table.to_string(),
590                root_id.to_string(),
591            ))
592        }
593    }
594
595    let refs = db.row_blob_refs_for_root(root_table, root_id).await?;
596    for reference in &refs {
597        if !matches!(
598            reference.authority(),
599            crate::blob::RowBlobAuthority::Remote(_)
600        ) || reference.stored().is_none()
601        {
602            return Err(MakeLocalError::UnresolvedLocality(
603                root_table.to_string(),
604                root_id.to_string(),
605            ));
606        }
607    }
608
609    // Validate every provided dest is UTF-8 up front — before any materialization — so
610    // a non-UTF-8 path aborts with nothing written and the cloud intact, rather than
611    // being caught only at commit-building after the destructive materialize (and on a
612    // filesystem that rejects non-UTF-8 names, the write would fail first and that
613    // check would never be reached). An external ref persists the path as a string, so
614    // a non-UTF-8 path cannot be registered; fail loud rather than lossily rewrite it
615    // and tombstone the cloud copy.
616    for (blob_id, path) in dest {
617        if path.to_str().is_none() {
618            return Err(MakeLocalError::NonUtf8Dest {
619                blob_id: blob_id.clone(),
620                path: path.display().to_string(),
621            });
622        }
623    }
624
625    // Any error after the first local copy is written must roll those files back, so
626    // an aborted make_local leaves no partial materialization behind. `written` tracks
627    // what to remove (typed by kind so the rollback treats a local-store leftover
628    // loud); the loop's result drives the cleanup-or-commit decision.
629    let mut written: Vec<PathBuf> = Vec::new();
630    let materialized = match materialize_blobs(
631        database,
632        storage,
633        store_dir,
634        observer,
635        root_table,
636        root_id,
637        &refs,
638        dest,
639        cancel,
640        &mut written,
641    )
642    .await
643    {
644        Ok(m) => m,
645        Err(e) => return Err(roll_back(&written, e).await),
646    };
647
648    let stamp = hlc.now().to_string();
649    let (root_table_owned, root_id_owned) = (root_table.to_string(), root_id.to_string());
650    let committed = materialized.clone();
651
652    // The single atomic commit: flip false + register external refs (user-provided
653    // only) + enqueue the cloud deletes, together. The destructive cloud delete is
654    // durable inside this commit, so a crash right after can never leave the root
655    // Local with the cloud blobs un-tombstoned.
656    let tables = db.synced_tables().to_vec();
657    let write_id = db.new_write_id();
658    let gates = db.gates();
659    db.call(move |conn| {
660        StoreDatabase::run_prepared_blob_transition_transaction_on(
661            conn,
662            &tables,
663            routing_encryption.as_ref(),
664            write_id,
665            |tx| {
666                let remote = Database::row_blob_refs_for_root_on(
667                    tx,
668                    &gates,
669                    &tables,
670                    &root_table_owned,
671                    &root_id_owned,
672                )?;
673                if remote.len() != committed.len()
674                    || remote
675                        .iter()
676                        .zip(&committed)
677                        .any(|(current, materialized)| {
678                            !same_row_blob_version(current, &materialized.remote)
679                                || current.authority() != materialized.remote.authority()
680                                || current.stored() != Some(&materialized.stored)
681                        })
682                {
683                    return Err(DbError::Message(format!(
684                        "make_local root {:?}/{:?} changed while its blobs were materialized",
685                        root_table_owned, root_id_owned
686                    )));
687                }
688                crate::sync::gate::write_gate(
689                    tx,
690                    &root_table_owned,
691                    &gate_col,
692                    false,
693                    &stamp,
694                    &root_id_owned,
695                )
696                .map_err(DbError::from)?;
697
698                // A new exact cloud object cannot bind to an existing blob row
699                // version. Restamp every blob-bearing child as part of the Local
700                // transition; the gated root itself was restamped by write_gate.
701                for materialized in &committed {
702                    let reference = &materialized.remote;
703                    if reference.table() == root_table_owned && reference.row_id() == root_id_owned
704                    {
705                        continue;
706                    }
707                    let sql = format!(
708                        "UPDATE {} SET _updated_at = ?1 WHERE id = ?2 AND _updated_at = ?3",
709                        crate::sync::session::quote_ident(reference.table())
710                    );
711                    let updated = tx
712                        .execute(
713                            &sql,
714                            rusqlite::params![&stamp, reference.row_id(), reference.row_stamp()],
715                        )
716                        .map_err(DbError::from)?;
717                    if updated != 1 {
718                        return Err(DbError::Message(format!(
719                            "make_local row {:?}/{:?} changed before restamping",
720                            reference.table(),
721                            reference.row_id()
722                        )));
723                    }
724                }
725                let local = Database::row_blob_refs_for_root_on(
726                    tx,
727                    &gates,
728                    &tables,
729                    &root_table_owned,
730                    &root_id_owned,
731                )?;
732                if local.len() != committed.len() {
733                    return Err(DbError::Message(format!(
734                        "make_local root {:?}/{:?} changed while its blobs were materialized",
735                        root_table_owned, root_id_owned
736                    )));
737                }
738                for (local, materialized) in local.iter().zip(&committed) {
739                    if local.table() != materialized.remote.table()
740                        || local.row_id() != materialized.remote.row_id()
741                        || local.column() != materialized.remote.column()
742                        || local.row_stamp() != stamp
743                        || local.blob() != materialized.remote.blob()
744                        || local.plaintext_size() != materialized.remote.plaintext_size()
745                        || local.plaintext_hash() != materialized.remote.plaintext_hash()
746                        || local.authority() != &crate::blob::RowBlobAuthority::Local
747                        || local.stored().is_some()
748                    {
749                        return Err(DbError::Message(format!(
750                            "make_local row {:?}/{:?}/{:?} changed while its blob was materialized",
751                            materialized.remote.table(),
752                            materialized.remote.row_id(),
753                            materialized.remote.column()
754                        )));
755                    }
756                    if let Some(path) = &materialized.dest {
757                        Database::register_external_blob_on(tx, local, path)?;
758                    }
759                    Database::enqueue_delete_on(tx, &materialized.stored, &stamp)?;
760                }
761                Ok(())
762            },
763        )
764    })
765    .await?;
766
767    if let Some(obs) = observer {
768        obs.on_root_made_local(root_table, root_id).await;
769    }
770    Ok(())
771}
772
773fn same_row_blob_version(left: &RowBlobRef, right: &RowBlobRef) -> bool {
774    left.table() == right.table()
775        && left.row_id() == right.row_id()
776        && left.row_stamp() == right.row_stamp()
777        && left.column() == right.column()
778        && left.blob() == right.blob()
779        && left.plaintext_size() == right.plaintext_size()
780        && left.plaintext_hash() == right.plaintext_hash()
781}
782
783/// Read each of `refs`'s blobs and write it durably to its local file, pushing each
784/// written path into `written` as it lands and returning the per-blob [`Materialized`]
785/// records the commit needs. A user-provided blob goes to its `dest` path (required,
786/// else [`MakeLocalError::MissingDest`]); a host-provided blob goes to coven's local
787/// store (no dest). Any error (cancel, a missing user-provided dest, a read or write
788/// failure, a key-derivation failure) returns early; the caller rolls back `written`.
789/// Separated from the commit so every error path runs that one rollback.
790#[allow(clippy::too_many_arguments)]
791async fn materialize_blobs(
792    database: &StoreDatabase,
793    storage: &dyn SyncStorage,
794    store_dir: &StoreDir,
795    observer: Option<&dyn BlobTransitionObserver>,
796    root_table: &str,
797    root_id: &str,
798    refs: &[RowBlobRef],
799    dest: &HashMap<String, PathBuf>,
800    cancel: &watch::Receiver<bool>,
801    written: &mut Vec<PathBuf>,
802) -> Result<Vec<Materialized>, MakeLocalError> {
803    let total = refs.len() as u64;
804    let mut materialized: Vec<Materialized> = Vec::new();
805
806    for (i, reference) in refs.iter().enumerate() {
807        if *cancel.borrow() {
808            return Err(MakeLocalError::Cancelled);
809        }
810        let blob = reference.blob();
811        let stored = reference.stored().cloned().ok_or_else(|| {
812            MakeLocalError::Read(
813                blob.id.clone(),
814                "remote row has no exact stored blob reference".to_string(),
815            )
816        })?;
817
818        // Where the blob's bytes go is its provenance's Local home: a user-provided
819        // blob to the user's chosen `dest` path (registered as an external ref); a
820        // host-provided blob to coven's local store (no path, no ref). The kind is
821        // recorded in `written` so an abort's rollback treats a local-store leftover
822        // loud.
823        let record = match blob.provenance {
824            Provenance::UserProvided => {
825                let dest_path = dest
826                    .get(&blob.id)
827                    .ok_or_else(|| MakeLocalError::MissingDest(blob.id.clone()))?
828                    .clone();
829                prepare_parent_dir(&dest_path)
830                    .await
831                    .map_err(|detail| MakeLocalError::Write {
832                        blob_id: blob.id.clone(),
833                        path: dest_path.display().to_string(),
834                        detail,
835                    })?;
836                let staged = crate::sync::store::blob::stage_remote_blob_plaintext(
837                    database,
838                    store_dir,
839                    Some(storage),
840                    reference,
841                    &dest_path,
842                )
843                .await
844                .map_err(|e| MakeLocalError::Read(blob.id.clone(), e.to_string()))?;
845                staged
846                    .commit_new()
847                    .await
848                    .map_err(|detail| MakeLocalError::Write {
849                        blob_id: blob.id.clone(),
850                        path: dest_path.display().to_string(),
851                        detail: detail.to_string(),
852                    })?;
853                verify_durable(&dest_path, reference.plaintext_size())
854                    .await
855                    .map_err(|detail| MakeLocalError::Write {
856                        blob_id: blob.id.clone(),
857                        path: dest_path.display().to_string(),
858                        detail,
859                    })?;
860                written.push(dest_path.clone());
861                Materialized {
862                    remote: reference.clone(),
863                    stored,
864                    dest: Some(dest_path),
865                }
866            }
867            Provenance::HostProvided => {
868                let store_path = store_dir
869                    .local_blob_path(&blob.namespace, &blob.id)
870                    .map_err(|e| MakeLocalError::Write {
871                        blob_id: blob.id.clone(),
872                        path: format!("local/{}/{}", blob.namespace, blob.id),
873                        detail: e.to_string(),
874                    })?;
875                let staged = crate::sync::store::blob::stage_remote_blob_plaintext(
876                    database,
877                    store_dir,
878                    Some(storage),
879                    reference,
880                    &store_path,
881                )
882                .await
883                .map_err(|e| MakeLocalError::Read(blob.id.clone(), e.to_string()))?;
884                match staged.commit_new().await {
885                    Ok(()) => written.push(store_path.clone()),
886                    Err(crate::local_blob::CommitNewFileError::DestinationExists(_)) => {
887                        let (size, hash) = crate::local_blob::exact_file_facts(&store_path)
888                            .await
889                            .map_err(|detail| MakeLocalError::Write {
890                            blob_id: blob.id.clone(),
891                            path: store_path.display().to_string(),
892                            detail,
893                        })?;
894                        if size != reference.plaintext_size() || hash != reference.plaintext_hash()
895                        {
896                            return Err(MakeLocalError::Write {
897                                blob_id: blob.id.clone(),
898                                path: store_path.display().to_string(),
899                                detail:
900                                    "existing local-store file differs from the exact remote blob"
901                                        .to_string(),
902                            });
903                        }
904                    }
905                    Err(error) => {
906                        return Err(MakeLocalError::Write {
907                            blob_id: blob.id.clone(),
908                            path: store_path.display().to_string(),
909                            detail: error.to_string(),
910                        });
911                    }
912                }
913                verify_durable(&store_path, reference.plaintext_size())
914                    .await
915                    .map_err(|detail| MakeLocalError::Write {
916                        blob_id: blob.id.clone(),
917                        path: store_path.display().to_string(),
918                        detail,
919                    })?;
920                Materialized {
921                    remote: reference.clone(),
922                    stored,
923                    dest: None,
924                }
925            }
926        };
927        materialized.push(record);
928
929        if let Some(obs) = observer {
930            obs.on_blob_materialize_progress(root_table, root_id, &blob.id, (i + 1) as u64, total)
931                .await;
932        }
933    }
934
935    if *cancel.borrow() {
936        return Err(MakeLocalError::Cancelled);
937    }
938    Ok(materialized)
939}
940
941/// Prove a materialized local file has the expected length and fsync its parent
942/// directory before make_local can tombstone the cloud copy. The materializer has
943/// already written through a temp file / rename path; this check is the
944/// Local-specific durability gate. A materialized local file is the ONLY copy once
945/// the cloud blob is tombstoned, so a directory-fsync failure is a hard error here:
946/// it aborts the make_local (the cloud copy is still intact) rather than commit a
947/// tombstone over a destination whose entry might not survive a crash.
948async fn verify_durable(dest: &std::path::Path, expected_size: u64) -> Result<(), String> {
949    let len = crate::local_blob::file_len(dest).await?;
950    if len != expected_size {
951        return Err(format!(
952            "dest {} is {len} bytes after materialize, expected {expected_size}",
953            dest.display()
954        ));
955    }
956
957    // fsync the parent dir so the rename's new entry is durable, not just the data.
958    // Hard error (see fn doc): the materialized file is the only copy after the commit.
959    crate::local_blob::sync_parent_dir(dest).await?;
960    Ok(())
961}
962
963async fn prepare_parent_dir(dest: &std::path::Path) -> Result<(), String> {
964    let parent = dest
965        .parent()
966        .ok_or_else(|| format!("blob path has no parent dir: {}", dest.display()))?;
967    crate::local_blob::create_dir_all(parent).await
968}
969
970/// Roll back the partial local copies an aborted make_local wrote, then return the
971/// error to surface. Returns the original `abort_err` when the rollback succeeds; if
972/// the rollback itself fails to remove a local-store leftover it returns THAT
973/// instead — the more urgent signal, since that leftover is a readable, budget-exempt
974/// copy of a still-Remote blob (a retry re-materializes over it).
975async fn roll_back(written: &[PathBuf], abort_err: MakeLocalError) -> MakeLocalError {
976    match cleanup_partial(written).await {
977        Ok(()) => abort_err,
978        Err(cleanup_err) => cleanup_err,
979    }
980}
981
982/// Delete every file this operation published before an aborted make_local.
983/// An already-absent path is idempotent; any filesystem failure is returned to
984/// the initiator because the operation cannot claim rollback while a destination
985/// remains visible.
986async fn cleanup_partial(written: &[PathBuf]) -> Result<(), MakeLocalError> {
987    for path in written {
988        crate::local_blob::remove_file(path)
989            .await
990            .map_err(|detail| MakeLocalError::Cleanup {
991                path: path.display().to_string(),
992                detail,
993            })?;
994    }
995    Ok(())
996}