1use 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#[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#[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
116fn 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
130fn 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
146pub 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
305pub 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
352pub(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#[derive(Clone)]
515struct Materialized {
516 remote: RowBlobRef,
517 stored: crate::blob::locator::StoredBlobRef,
518 dest: Option<PathBuf>,
519}
520
521#[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 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 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 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 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 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#[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 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
941async 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 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
970async 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
982async 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}