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