1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use rusqlite::{Connection, OptionalExtension};
5
6use crate::BlobDecls;
7use crate::PublicationBlob;
8use crate::WriteId;
9use crate::{
10 audience_moves, capture_routing_changes, partition_outbound,
11 validate_scoped_foreign_key_audiences, AudienceMove, AudiencePartition, Gates, RoutingChanges,
12};
13use crate::{capture_changeset, *};
14
15use coven_protocol::blob::Provenance;
16
17use coven_protocol::synced_schema::SyncedTable;
18
19use coven_keys::encryption::EncryptionService;
20use coven_protocol::write::WriteReceipt;
21
22use super::*;
23
24pub type StagedAudienceBlobRollback = Box<dyn FnOnce(DbError) -> DbError + Send>;
27
28fn rollback_staged_audience_blobs(
29 rollback: Option<StagedAudienceBlobRollback>,
30 error: DbError,
31) -> DbError {
32 match rollback {
33 Some(rollback) => rollback(error),
34 None => error,
35 }
36}
37
38pub trait AudienceBlobMoveStaging: Send + Sync {
42 fn stage_audience_move_blobs_on(
43 &self,
44 transaction: &mut HostWriteBlobTransaction<'_, '_>,
45 facts: &mut StoreWriteBlobFacts,
46 moves: &[AudienceMove],
47 partitions: &[AudiencePartition],
48 ) -> Result<StagedAudienceBlobRollback, DbError>;
49}
50
51enum AudienceBlobMoveMaterialization<'a> {
52 Host(&'a dyn AudienceBlobMoveStaging),
53 PreparedTransition,
54}
55
56pub(crate) struct CapturedStoreWriteTransaction<'connection, 'operation> {
57 transaction: rusqlite::Transaction<'connection>,
58 store_dir: &'operation coven_foundation::store_dir::StoreDir,
61 synced_tables: &'operation [SyncedTable],
62 gates: &'operation Gates,
63 blob_decls: &'operation BlobDecls,
64 routing: StoreWriteRouting<'operation>,
65 blob_materialization: Option<AudienceBlobMoveMaterialization<'operation>>,
66 verified_authority: &'operation mut super::verified_store_authority::VerifiedStoreAuthority,
67 write_id: WriteId,
68}
69
70pub struct HostWriteBlobTransaction<'transaction, 'connection> {
71 store: crate::store::store_session::StoreTransaction<'transaction, 'connection>,
72 verified_authority: &'transaction mut super::verified_store_authority::VerifiedStoreAuthority,
73}
74
75impl StoreSession<'_> {
76 fn prepare_store_write(&self) -> Result<Option<PreparedStoreWrite>, DbError> {
77 let stored = self
78 .conn
79 .query_row(
80 "SELECT write_id, base, blob_facts FROM store_writes
81 WHERE status = '\"pending\"'
82 AND ordinal = (
83 SELECT MIN(ordinal) FROM store_writes
84 WHERE status != '\"local_only\"'
85 AND json_extract(status, '$.published') IS NULL
86 AND json_extract(status, '$.resolved') IS NULL
87 )
88 AND NOT EXISTS (
89 SELECT 1 FROM store_writes WHERE prepared IS NOT NULL
90 )
91 ORDER BY ordinal LIMIT 1",
92 [],
93 |row| {
94 Ok((
95 row.get::<_, String>(0)?,
96 row.get::<_, Option<String>>(1)?,
97 row.get::<_, Option<String>>(2)?,
98 ))
99 },
100 )
101 .optional()
102 .map_err(DbError::from)?;
103 let Some((write_id, base, blob_facts)) = stored else {
104 return Ok(None);
105 };
106 let (Some(base), Some(blob_facts)) = (base, blob_facts) else {
107 return Err(DbError::Message(format!(
108 "pending write {write_id} carries no commit base or blob facts"
109 )));
110 };
111 let partitions = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
112 .store_write_partitions(&write_id)?;
113 Ok(Some(PreparedStoreWrite {
114 write_id: WriteId::from_generated(write_id),
115 partitions,
116 base: serde_json::from_str(&base)
117 .map_err(|error| DbError::context("pending write base", error))?,
118 blob_facts: serde_json::from_str(&blob_facts)
119 .map_err(|error| DbError::context("pending write blob facts", error))?,
120 }))
121 }
122}
123
124impl<'transaction, 'connection> HostWriteBlobTransaction<'transaction, 'connection> {
125 fn new(
126 store: crate::store::store_session::StoreTransaction<'transaction, 'connection>,
127 verified_authority: &'transaction mut super::verified_store_authority::VerifiedStoreAuthority,
128 ) -> Self {
129 Self {
130 store,
131 verified_authority,
132 }
133 }
134
135 pub fn local_activated_registration(
136 &mut self,
137 root: &coven_protocol::store_commit::StoreRootRef,
138 ) -> Result<coven_protocol::store_commit::ReferencedStoreDeviceRegistration, DbError> {
139 let reference =
140 local_activated_registration_ref_on(self.store.transaction)?.ok_or_else(|| {
141 DbError::Message(
142 "audience blob move has no activated local Store registration".to_string(),
143 )
144 })?;
145 let registration =
146 self.store
147 .activated_registration(self.verified_authority, root, &reference)?;
148 coven_protocol::store_commit::ReferencedStoreDeviceRegistration::verified(
149 reference,
150 registration,
151 )
152 .map_err(DbError::from)
153 }
154
155 pub fn circle_publication_context(
156 &self,
157 circle_id: coven_protocol::circle::CircleId,
158 expected_control: &coven_protocol::circle::CircleControlCoord,
159 ) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
160 super::circle_publication_context_on(self.store.transaction, circle_id, expected_control)
161 }
162
163 pub fn circle_blob_opening_protection(
164 &mut self,
165 root: &coven_protocol::store_commit::StoreRootRef,
166 circle_id: coven_protocol::circle::CircleId,
167 expected_control: &coven_protocol::circle::CircleControlCoord,
168 expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
169 ) -> Result<coven_protocol::objects::BlobSpoolProtection, DbError> {
170 self.store.circle_blob_opening_protection(
171 self.verified_authority,
172 root,
173 circle_id,
174 expected_control,
175 expected_key_fingerprint,
176 )
177 }
178
179 pub fn external_local_path(
180 &self,
181 fact: &StoreWriteBlobFact,
182 ) -> Result<Option<PathBuf>, DbError> {
183 let stored = self
184 .store
185 .transaction
186 .query_row(
187 "SELECT path, plaintext_size, plaintext_hash
188 FROM local_blob_refs
189 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
190 AND namespace = ?4 AND blob_id = ?5
191 ORDER BY row_stamp DESC LIMIT 1",
192 rusqlite::params![
193 fact.table,
194 fact.row_id,
195 fact.column,
196 fact.blob.namespace,
197 fact.blob.id,
198 ],
199 |row| {
200 Ok((
201 row.get::<_, String>(0)?,
202 row.get::<_, i64>(1)?,
203 row.get::<_, String>(2)?,
204 ))
205 },
206 )
207 .optional()
208 .map_err(DbError::from)?;
209 let Some((path, size, hash)) = stored else {
210 return Ok(None);
211 };
212 let size = u64::try_from(size).map_err(|_| {
213 DbError::Message("registered external blob has a negative size".to_string())
214 })?;
215 if size != fact.plaintext_size || hash != fact.plaintext_hash.to_string() {
216 return Err(DbError::Message(
217 "registered external blob identity differs from the moved row".to_string(),
218 ));
219 }
220 Ok(Some(PathBuf::from(path)))
221 }
222}
223
224fn deleted_rows(captured: &[u8]) -> Result<std::collections::HashSet<(String, String)>, DbError> {
231 Ok(crate::walk_changeset(captured)
232 .map_err(DbError::Changeset)?
233 .into_iter()
234 .filter(|change| matches!(change.op, coven_foundation::changeset::ChangeOp::Delete))
235 .filter_map(|change| change.pk().map(|id| (change.table.clone(), id.to_string())))
236 .collect())
237}
238
239impl StoreDatabase {
240 fn drain_host_change_journal_on(
241 session: &mut rusqlite::session::Session<'_>,
242 ) -> Result<Vec<u8>, DbError> {
243 capture_changeset(session)
244 }
245
246 fn drain_host_change_journal(
250 session: &mut rusqlite::session::Session<'_>,
251 synced_tables: &[SyncedTable],
252 ) -> Result<Vec<u8>, DbError> {
253 let captured = Self::drain_host_change_journal_on(session)?;
254 crate::changeset_identity::validate_changeset_row_identities(&captured, synced_tables)
255 .map_err(DbError::from)?;
256 Ok(captured)
257 }
258
259 pub fn invert_changeset(changeset: &[u8]) -> Result<Vec<u8>, DbError> {
260 if changeset.is_empty() {
261 return Ok(Vec::new());
262 }
263 let mut inverse = Vec::new();
264 rusqlite::session::invert_strm(&mut &changeset[..], &mut inverse).map_err(DbError::from)?;
265 Ok(inverse)
266 }
267
268 fn capture_store_write_blob_facts_on(
269 tx: &rusqlite::Transaction<'_>,
270 changeset: &[u8],
271 blob_decls: &BlobDecls,
272 ) -> Result<StoreWriteBlobFacts, DbError> {
273 let changes = crate::walk_changeset(changeset)
274 .map_err(|error| DbError::context("read Store write blobs", error))?;
275 let mut facts = BTreeMap::new();
276 for change in changes {
277 let Some(publication) = blob_decls
278 .publication_blob_from_change(tx, &change)
279 .map_err(|error| DbError::context("capture Store write blob", error))?
280 else {
281 continue;
282 };
283 let fact = Self::capture_store_write_blob_fact_on(tx, publication)?;
284 let key = fact.identity_key();
285 if let Some(prior) = facts.insert(key.clone(), fact.clone()) {
286 if prior != fact {
287 return Err(DbError::Message(format!(
288 "Store write gives row {}/{}/{} at {} conflicting blob facts",
289 key.0, key.1, key.2, key.3
290 )));
291 }
292 }
293 }
294 Ok(StoreWriteBlobFacts {
295 blobs: facts.into_values().collect(),
296 })
297 }
298
299 fn capture_store_write_blob_fact_on(
300 tx: &rusqlite::Transaction<'_>,
301 publication: PublicationBlob,
302 ) -> Result<StoreWriteBlobFact, DbError> {
303 let plaintext_hash = publication.plaintext_hash.parse().map_err(|error| {
304 DbError::context(
305 format!(
306 "capture Store write blob {}/{} plaintext hash",
307 publication.blob.namespace, publication.blob.id
308 ),
309 error,
310 )
311 })?;
312 let external_path = if publication.blob.provenance == Provenance::UserProvided {
313 tx.query_row(
314 "SELECT path FROM local_blob_refs
315 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
316 AND row_stamp = ?4 AND namespace = ?5 AND blob_id = ?6",
317 rusqlite::params![
318 publication.table,
319 publication.row_id,
320 publication.column,
321 publication.row_stamp,
322 publication.blob.namespace,
323 publication.blob.id,
324 ],
325 |row| row.get::<_, String>(0),
326 )
327 .optional()
328 .map_err(DbError::from)?
329 .map(PathBuf::from)
330 } else {
331 None
332 };
333 let previous = previous_row_blob_for_write_on(
334 tx,
335 &publication.table,
336 &publication.row_id,
337 &publication.row_stamp,
338 &publication.column,
339 &publication.blob,
340 publication.plaintext_size,
341 plaintext_hash,
342 )?;
343 Ok(StoreWriteBlobFact {
344 table: publication.table,
345 row_id: publication.row_id,
346 row_stamp: publication.row_stamp,
347 column: publication.column,
348 blob: publication.blob,
349 plaintext_size: publication.plaintext_size,
350 plaintext_hash,
351 external_path,
352 previous,
353 audience_move: None,
354 })
355 }
356
357 fn capture_audience_move_blob_facts_on(
358 tx: &rusqlite::Transaction<'_>,
359 moves: &[AudienceMove],
360 blob_decls: &BlobDecls,
361 captured: StoreWriteBlobFacts,
362 ) -> Result<StoreWriteBlobFacts, DbError> {
363 let mut facts = captured
364 .blobs
365 .into_iter()
366 .map(|fact| (fact.identity_key(), fact))
367 .collect::<BTreeMap<_, _>>();
368 for audience_move in moves {
369 for (table, row_id) in &audience_move.rows {
370 let Some(publication) = blob_decls
371 .publication_blob_for_row(tx, table, row_id)
372 .map_err(|error| {
373 DbError::context(
374 format!("capture audience-move blob {table}/{row_id}"),
375 error,
376 )
377 })?
378 else {
379 continue;
380 };
381 let fact = Self::capture_store_write_blob_fact_on(tx, publication)?;
382 let key = fact.identity_key();
383 if let Some(prior) = facts.get(&key) {
384 if prior != &fact {
385 return Err(DbError::Message(format!(
386 "audience move gives row {}/{}/{} at {} conflicting blob facts",
387 key.0, key.1, key.2, key.3
388 )));
389 }
390 } else {
391 facts.insert(key, fact);
392 }
393 }
394 }
395 Ok(StoreWriteBlobFacts {
396 blobs: facts.into_values().collect(),
397 })
398 }
399
400 fn advance_moved_blob_row_stamps_on(
412 tx: &rusqlite::Transaction<'_>,
413 moves: &[AudienceMove],
414 blob_decls: &BlobDecls,
415 ) -> Result<bool, DbError> {
416 let mut advanced = false;
417 for audience_move in moves {
418 for (table, row_id) in &audience_move.rows {
419 let carries_blob = blob_decls
420 .publication_blob_for_row(tx, table, row_id)
421 .map_err(|error| {
422 DbError::context(
423 format!("read audience-move blob row {table}/{row_id}"),
424 error,
425 )
426 })?
427 .is_some();
428 if !carries_blob {
429 continue;
430 }
431 let sql = format!(
432 "UPDATE {} SET {} = ?1 WHERE {} = ?2 AND {} < ?1",
433 crate::quote_ident(table),
434 crate::quote_ident("_updated_at"),
435 crate::quote_ident("id"),
436 crate::quote_ident("_updated_at"),
437 );
438 let updated = tx
439 .execute(&sql, rusqlite::params![audience_move.stamp, row_id])
440 .map_err(DbError::from)?;
441 advanced |= updated > 0;
442 }
443 }
444 Ok(advanced)
445 }
446
447 fn store_write_routing<'a>(
448 has_scoped_graph: bool,
449 routing_encryption: Option<&'a EncryptionService>,
450 ) -> Result<StoreWriteRouting<'a>, DbError> {
451 if !has_scoped_graph {
452 return Ok(StoreWriteRouting::Unscoped);
453 }
454 routing_encryption
455 .map(StoreWriteRouting::MergeScoped)
456 .ok_or_else(|| {
457 DbError::Message(
458 "scoped write requires the Store generation-1 routing key".to_string(),
459 )
460 })
461 }
462
463 pub fn validate_store_write_routing(
464 &self,
465 routing_encryption: Option<&EncryptionService>,
466 ) -> Result<(), DbError> {
467 Self::store_write_routing(self.has_scoped_graph(), routing_encryption).map(drop)
468 }
469
470 pub async fn prepare_store_write(&self) -> Result<Option<PreparedStoreWrite>, DbError> {
471 self.call_store(|session| session.prepare_store_write())
472 .await
473 }
474}
475
476pub(crate) fn capture_partition_blob_facts_on(
477 tx: &rusqlite::Transaction<'_>,
478 partitions: &[AudiencePartition],
479 blob_decls: &BlobDecls,
480) -> Result<StoreWriteBlobFacts, DbError> {
481 let mut facts = BTreeMap::new();
482 for partition in partitions {
483 for fact in
484 StoreDatabase::capture_store_write_blob_facts_on(tx, &partition.changeset, blob_decls)?
485 .blobs
486 {
487 let key = fact.identity_key();
488 if let Some(prior) = facts.insert(key.clone(), fact.clone()) {
489 if prior != fact {
490 return Err(DbError::Message(format!(
491 "audience partitions give row {}/{}/{} at {} conflicting blob facts",
492 key.0, key.1, key.2, key.3
493 )));
494 }
495 }
496 }
497 }
498 Ok(StoreWriteBlobFacts {
499 blobs: facts.into_values().collect(),
500 })
501}
502
503impl<'connection, 'operation> CapturedStoreWriteTransaction<'connection, 'operation> {
504 #[allow(clippy::too_many_arguments)]
505 pub(crate) fn begin_host(
506 connection: &'connection Connection,
507 store_dir: &'operation coven_foundation::store_dir::StoreDir,
508 synced_tables: &'operation [SyncedTable],
509 gates: &'operation Gates,
510 blob_decls: &'operation BlobDecls,
511 routing_encryption: Option<&'operation EncryptionService>,
512 blob_staging: Option<&'operation dyn AudienceBlobMoveStaging>,
513 verified_authority: &'operation mut super::verified_store_authority::VerifiedStoreAuthority,
514 write_id: WriteId,
515 ) -> Result<Self, DbError> {
516 Self::begin(
517 connection,
518 store_dir,
519 synced_tables,
520 gates,
521 blob_decls,
522 routing_encryption,
523 blob_staging.map(AudienceBlobMoveMaterialization::Host),
524 verified_authority,
525 write_id,
526 )
527 }
528
529 pub(crate) fn begin_prepared_blob_transition(
530 connection: &'connection Connection,
531 store_dir: &'operation coven_foundation::store_dir::StoreDir,
532 synced_tables: &'operation [SyncedTable],
533 gates: &'operation Gates,
534 blob_decls: &'operation BlobDecls,
535 routing_encryption: Option<&'operation EncryptionService>,
536 verified_authority: &'operation mut super::verified_store_authority::VerifiedStoreAuthority,
537 write_id: WriteId,
538 ) -> Result<Self, DbError> {
539 Self::begin(
540 connection,
541 store_dir,
542 synced_tables,
543 gates,
544 blob_decls,
545 routing_encryption,
546 Some(AudienceBlobMoveMaterialization::PreparedTransition),
547 verified_authority,
548 write_id,
549 )
550 }
551
552 #[allow(clippy::too_many_arguments)]
553 fn begin(
554 connection: &'connection Connection,
555 store_dir: &'operation coven_foundation::store_dir::StoreDir,
556 synced_tables: &'operation [SyncedTable],
557 gates: &'operation Gates,
558 blob_decls: &'operation BlobDecls,
559 routing_encryption: Option<&'operation EncryptionService>,
560 blob_materialization: Option<AudienceBlobMoveMaterialization<'operation>>,
561 verified_authority: &'operation mut super::verified_store_authority::VerifiedStoreAuthority,
562 write_id: WriteId,
563 ) -> Result<Self, DbError> {
564 let routing =
565 StoreDatabase::store_write_routing(gates.has_scoped_graph(), routing_encryption)?;
566 let transaction = connection.unchecked_transaction().map_err(DbError::from)?;
567 Ok(Self {
568 transaction,
569 store_dir,
570 synced_tables,
571 gates,
572 blob_decls,
573 routing,
574 blob_materialization,
575 verified_authority,
576 write_id,
577 })
578 }
579
580 pub(crate) fn execute_host<R, E>(
581 self,
582 mut staged: super::host_write_operation::StagedBlobBatch,
583 deleted: Vec<coven_protocol::blob::BlobRef>,
584 sql: super::host_write_operation::HostSql<R, E>,
585 stamper: coven_protocol::hlc::UpdatedAtStamper,
586 ) -> Result<WriteReceipt<R>, super::host_write_operation::HostWriteError<E>> {
587 use super::host_sql_transaction::HostSqlAuthorization;
588 use super::host_write_operation::HostWriteError;
589
590 let blob_decls = self.blob_decls;
591 let store_dir = self.store_dir;
592 let synced_tables = self.synced_tables;
593 let gates = self.gates;
594 let result = self.execute(|transaction| -> Result<R, HostWriteError<E>> {
595 let cleanup_intents = deleted
596 .iter()
597 .map(|blob| {
598 blob_decls
599 .row_for_blob_in_namespace(transaction, &blob.namespace, &blob.id)
600 .map_err(HostWriteError::BlobDeclaration)
601 .map(|row| match row {
602 Some((table, row_id)) => {
603 crate::local_blob_cleanup_intents::LocalBlobCleanupIntent::for_row(
604 &blob.namespace,
605 &blob.id,
606 table,
607 row_id,
608 )
609 }
610 None => {
611 crate::local_blob_cleanup_intents::LocalBlobCleanupIntent::local(
612 &blob.namespace,
613 &blob.id,
614 )
615 }
616 })
617 })
618 .collect::<Result<Vec<_>, _>>()?;
619
620 staged.publish(|namespace, id| {
621 match blob_decls.row_for_blob_in_namespace(transaction, namespace, id) {
622 Ok(Some(_)) => {
623 return Err(HostWriteError::BlobAlreadyReferenced {
624 namespace: namespace.to_string(),
625 id: id.to_string(),
626 });
627 }
628 Ok(None) => {}
629 Err(error) => return Err(HostWriteError::BlobDeclaration(error)),
630 }
631 let leased = transaction
632 .query_row(
633 "SELECT EXISTS(\
634 SELECT 1 FROM store_write_blob_leases \
635 WHERE namespace = ?1 AND blob_id = ?2\
636 ) OR EXISTS(\
637 SELECT 1 FROM retained_replay_blob_leases \
638 WHERE namespace = ?1 AND blob_id = ?2\
639 )",
640 (namespace, id),
641 |row| row.get::<_, bool>(0),
642 )
643 .map_err(DbError::from)?;
644 if leased {
645 return Err(HostWriteError::BlobOwnedByPendingWrite {
646 namespace: namespace.to_string(),
647 id: id.to_string(),
648 });
649 }
650 Ok(())
651 })?;
652
653 let host_sql = HostSqlAuthorization::begin(transaction)?;
654 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
655 host_sql.run_observing_write(|| {
656 sql(super::SqlContext::new(
657 transaction,
658 stamper,
659 synced_tables,
660 gates,
661 ))
662 })
663 })) {
664 Ok((Ok(value), true)) => {
665 for (blob, intent) in deleted.iter().zip(&cleanup_intents) {
666 let _ = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
667 if blob_decls
668 .blob_id_is_referenced(transaction, &blob.namespace, &blob.id)
669 .map_err(DbError::from)?
670 {
671 return Err(HostWriteError::BlobStillReferenced {
672 namespace: blob.namespace.clone(),
673 id: blob.id.clone(),
674 });
675 }
676 super::local_blob_cleanup::record_obsolete_copy_intents_on(
677 transaction,
678 blob_decls,
679 intent,
680 )?;
681 }
682 Ok(value)
683 }
684 Ok((Ok(_), false)) => Err(HostWriteError::from(DbError::ReadOnlyWriteTransaction)),
685 Ok((Err(error), _)) => Err(HostWriteError::Host(error)),
686 Err(_) => Err(HostWriteError::WriteClosurePanicked),
687 }
688 });
689
690 match result {
691 Ok(receipt) => {
692 staged.commit();
693 Ok(receipt)
694 }
695 Err(error) => Err(staged.rollback(error)),
696 }
697 }
698
699 #[allow(clippy::too_many_arguments)]
700 pub(crate) fn execute_make_remote(
701 self,
702 root_table: String,
703 root_id: String,
704 gate_column: String,
705 stamp: String,
706 rows: Vec<coven_protocol::blob::RowBlobRef>,
707 publication_write_id: WriteId,
708 ) -> Result<WriteReceipt<()>, DbError> {
709 self.execute(|transaction| {
710 super::blob_transitions::write_gate(
711 transaction,
712 &root_table,
713 &gate_column,
714 true,
715 &stamp,
716 &root_id,
717 )
718 .map_err(DbError::from)?;
719 let external_blobs = ExternalBlobRecords::new(transaction);
720 for reference in &rows {
721 if reference.blob().provenance == Provenance::UserProvided {
722 external_blobs.clear(reference)?;
723 }
724 }
725 Database::mark_make_remote_publishing_on(
726 transaction,
727 &root_table,
728 &root_id,
729 &publication_write_id,
730 )
731 })
732 }
733
734 #[allow(clippy::too_many_arguments)]
735 pub(crate) fn execute_make_local(
736 self,
737 root_table: String,
738 root_id: String,
739 gate_column: String,
740 stamp: String,
741 materialized: Vec<super::blob_transitions::MaterializedLocalBlob>,
742 ) -> Result<WriteReceipt<()>, DbError> {
743 let gates = self.gates;
744 let synced_tables = self.synced_tables;
745 self.execute(|transaction| {
746 let remote = Database::row_blob_refs_for_root_on(
747 transaction,
748 gates,
749 synced_tables,
750 &root_table,
751 &root_id,
752 )?;
753 if remote.len() != materialized.len()
754 || remote.iter().zip(&materialized).any(|(current, local)| {
755 !super::blob_transitions::same_row_blob_version(current, &local.remote)
756 || current.authority() != local.remote.authority()
757 || current.stored() != Some(&local.stored)
758 })
759 {
760 return Err(DbError::Message(format!(
761 "make_local root {root_table:?}/{root_id:?} changed while its blobs were materialized"
762 )));
763 }
764 super::blob_transitions::write_gate(
765 transaction,
766 &root_table,
767 &gate_column,
768 false,
769 &stamp,
770 &root_id,
771 )
772 .map_err(DbError::from)?;
773
774 for local in &materialized {
775 let reference = &local.remote;
776 if reference.table() == root_table && reference.row_id() == root_id {
777 continue;
778 }
779 let sql = format!(
780 "UPDATE {} SET _updated_at = ?1 WHERE id = ?2 AND _updated_at = ?3",
781 crate::quote_ident(reference.table())
782 );
783 let updated = transaction
784 .execute(
785 &sql,
786 rusqlite::params![stamp, reference.row_id(), reference.row_stamp()],
787 )
788 .map_err(DbError::from)?;
789 if updated != 1 {
790 return Err(DbError::Message(format!(
791 "make_local row {:?}/{:?} changed before restamping",
792 reference.table(),
793 reference.row_id()
794 )));
795 }
796 }
797 let local_rows = Database::row_blob_refs_for_root_on(
798 transaction,
799 gates,
800 synced_tables,
801 &root_table,
802 &root_id,
803 )?;
804 if local_rows.len() != materialized.len() {
805 return Err(DbError::Message(format!(
806 "make_local root {root_table:?}/{root_id:?} changed while its blobs were materialized"
807 )));
808 }
809 let cloud_outbox = CloudOutboxRecords::new(transaction);
810 let external_blobs = ExternalBlobRecords::new(transaction);
811 for (local, materialized) in local_rows.iter().zip(&materialized) {
812 if local.table() != materialized.remote.table()
813 || local.row_id() != materialized.remote.row_id()
814 || local.column() != materialized.remote.column()
815 || local.row_stamp() != stamp
816 || local.blob() != materialized.remote.blob()
817 || local.plaintext_size() != materialized.remote.plaintext_size()
818 || local.plaintext_hash() != materialized.remote.plaintext_hash()
819 || local.authority() != &coven_protocol::blob::RowBlobAuthority::Local
820 || local.stored().is_some()
821 {
822 return Err(DbError::Message(format!(
823 "make_local row {:?}/{:?}/{:?} changed while its blob was materialized",
824 materialized.remote.table(),
825 materialized.remote.row_id(),
826 materialized.remote.column()
827 )));
828 }
829 if let Some(path) = &materialized.destination {
830 external_blobs.register(local, path)?;
831 }
832 cloud_outbox.enqueue_delete(&materialized.stored, &stamp)?;
833 }
834 Ok(())
835 })
836 }
837
838 fn execute<R, E>(
839 self,
840 f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<R, E>,
841 ) -> Result<WriteReceipt<R>, E>
842 where
843 E: From<DbError>,
844 {
845 let Self {
846 transaction: tx,
847 store_dir,
848 synced_tables,
849 gates,
850 blob_decls,
851 routing,
852 blob_materialization,
853 verified_authority,
854 write_id,
855 } = self;
856 (|| {
857 let mut journal = rusqlite::session::Session::new(&tx)
858 .map_err(|error| DbError::context("failed to create capture session", error))
859 .map_err(E::from)?;
860 for table in synced_tables {
861 journal
862 .attach(Some(table.name()))
863 .map_err(|error| {
864 DbError::context(
865 format!("failed to attach synced table {} to session", table.name()),
866 error,
867 )
868 })
869 .map_err(E::from)?;
870 }
871 if gates.has_scoped_graph() {
872 for table in ["_coven_audience", "_coven_row_routes"] {
873 journal
874 .attach(Some(table))
875 .map_err(DbError::from)
876 .map_err(E::from)?;
877 }
878 }
879 let value = f(&tx)?;
880 let mut captured =
881 StoreDatabase::drain_host_change_journal(&mut journal, synced_tables)
882 .map_err(E::from)?;
883 crate::Database::cancel_transitions_for_deleted_roots_on(
888 &tx,
889 &deleted_rows(&captured).map_err(E::from)?,
890 )
891 .map_err(E::from)?;
892 validate_scoped_foreign_key_audiences(&tx, gates)
893 .map_err(DbError::from)
894 .map_err(E::from)?;
895 if matches!(
904 blob_materialization,
905 Some(AudienceBlobMoveMaterialization::Host(_))
906 ) {
907 let moves = audience_moves(&tx, &captured, gates)
908 .map_err(DbError::from)
909 .map_err(E::from)?;
910 if StoreDatabase::advance_moved_blob_row_stamps_on(&tx, &moves, blob_decls)
911 .map_err(E::from)?
912 {
913 captured =
914 StoreDatabase::drain_host_change_journal(&mut journal, synced_tables)
915 .map_err(E::from)?;
916 }
917 }
918 blob_decls
919 .validate_changed_rows(&tx, &captured)
920 .map_err(DbError::from)
921 .map_err(E::from)?;
922 let partitioned = match routing {
923 StoreWriteRouting::MergeScoped(encryption) => {
924 let store_root_hash =
925 crate::store::store_session::StoreTransaction::new(&tx, store_dir)
926 .required_root_authority(verified_authority)
927 .map_err(E::from)?
928 .store_root_hash;
929 let key =
930 coven_protocol::circle::derive_row_routing_key(encryption, store_root_hash)
931 .map_err(|error| {
932 E::from(DbError::context("derive row routing key", error))
933 })?;
934 let routing_changeset = capture_routing_changes(&tx, &captured, gates, &key)
935 .map_err(|error| {
936 E::from(DbError::context("capture scoped routing changes", error))
937 })?;
938 partition_outbound(&tx, &captured, &routing_changeset, gates).map_err(
939 |error| {
940 E::from(DbError::context("partition scoped host transaction", error))
941 },
942 )?
943 }
944 StoreWriteRouting::Unscoped => {
945 partition_outbound(&tx, &captured, &RoutingChanges::empty(), gates).map_err(
946 |error| {
947 E::from(DbError::context("partition gated host transaction", error))
948 },
949 )?
950 }
951 };
952 if partitioned.partitions.is_empty() && partitioned.moves.is_empty() {
964 drop(journal);
965 tx.commit().map_err(DbError::from).map_err(E::from)?;
966 return Ok(WriteReceipt {
967 value,
968 write_id,
969 status: coven_protocol::write::WriteStatus::LocalOnly,
970 });
971 }
972 let mut blob_facts =
973 capture_partition_blob_facts_on(&tx, &partitioned.partitions, blob_decls)
974 .map_err(E::from)?;
975 blob_facts = StoreDatabase::capture_audience_move_blob_facts_on(
976 &tx,
977 &partitioned.moves,
978 blob_decls,
979 blob_facts,
980 )
981 .map_err(E::from)?;
982 let moved_blob_exists = blob_facts.blobs.iter().any(|fact| {
983 partitioned.moves.iter().any(|audience_move| {
984 audience_move
985 .rows
986 .contains(&(fact.table.clone(), fact.row_id.clone()))
987 })
988 });
989 let staged_files = match (moved_blob_exists, &blob_materialization) {
990 (false, _) => None,
991 (true, Some(AudienceBlobMoveMaterialization::Host(staging))) => {
992 let mut blob_transaction = HostWriteBlobTransaction::new(
993 crate::store::store_session::StoreTransaction::new(&tx, store_dir),
994 verified_authority,
995 );
996 Some(
997 staging
998 .stage_audience_move_blobs_on(
999 &mut blob_transaction,
1000 &mut blob_facts,
1001 &partitioned.moves,
1002 &partitioned.partitions,
1003 )
1004 .map_err(E::from)?,
1005 )
1006 }
1007 (true, Some(AudienceBlobMoveMaterialization::PreparedTransition)) => {
1008 record_prepared_transition_local_blob_moves(
1009 &mut blob_facts,
1010 &partitioned.moves,
1011 )
1012 .map_err(E::from)?;
1013 None
1014 }
1015 (true, None) => {
1016 return Err(E::from(DbError::Message(
1017 "BlobMoveRequiresMaterialization: audience move staging is unavailable"
1018 .to_string(),
1019 )));
1020 }
1021 };
1022 let changeset_hash = match (|| -> Result<ObjectHash, DbError> {
1023 let mut changeset_writer =
1024 crate::store::store_session::StoreTransaction::new(&tx, store_dir)
1025 .payload_writer();
1026 journal.changeset_strm(&mut changeset_writer)?;
1027 Ok(changeset_writer.commit()?.0)
1028 })() {
1029 Ok(hash) => hash,
1030 Err(error) => {
1031 return Err(E::from(rollback_staged_audience_blobs(staged_files, error)));
1032 }
1033 };
1034 drop(journal);
1035 let committed = (|| {
1036 let base = StoreWriteBase {
1037 dependencies:
1038 crate::store::materialized_commit_index::materialized_frontier_on(
1039 &tx, None,
1040 )?,
1041 };
1042 let status = crate::store::store_session::StoreTransaction::new(&tx, store_dir)
1043 .insert_store_write(
1044 &write_id,
1045 &partitioned.partitions,
1046 changeset_hash,
1047 &base,
1048 &blob_facts,
1049 )?;
1050 tx.commit().map_err(DbError::from)?;
1051 Ok::<_, DbError>(status)
1052 })();
1053 let status = match committed {
1054 Ok(status) => status,
1055 Err(error) => {
1056 return Err(E::from(rollback_staged_audience_blobs(staged_files, error)));
1057 }
1058 };
1059 Ok(WriteReceipt {
1060 value,
1061 write_id,
1062 status,
1063 })
1064 })()
1065 }
1066}
1067
1068pub(crate) fn record_prepared_transition_local_blob_moves(
1069 facts: &mut StoreWriteBlobFacts,
1070 moves: &[AudienceMove],
1071) -> Result<(), DbError> {
1072 let moved_rows = audience_moves_by_row(moves)?;
1073 for fact in &mut facts.blobs {
1074 let Some(audience_move) = moved_rows.get(&(fact.table.clone(), fact.row_id.clone())) else {
1075 continue;
1076 };
1077 if audience_move.destination == coven_protocol::circle::Audience::Local {
1078 fact.audience_move = Some(StoreWriteBlobMoveDestination::Local);
1079 }
1080 }
1081 Ok(())
1082}
1083
1084pub fn audience_moves_by_row(
1085 moves: &[AudienceMove],
1086) -> Result<BTreeMap<(String, String), &AudienceMove>, DbError> {
1087 let mut moved_rows = BTreeMap::new();
1088 for audience_move in moves {
1089 for row in &audience_move.rows {
1090 if let Some(prior) = moved_rows.insert(row.clone(), audience_move) {
1091 if prior.source != audience_move.source
1092 || prior.destination != audience_move.destination
1093 {
1094 return Err(DbError::Message(format!(
1095 "row {}/{} belongs to conflicting audience moves",
1096 row.0, row.1
1097 )));
1098 }
1099 }
1100 }
1101 }
1102 Ok(moved_rows)
1103}