Skip to main content

coven_database/store/store_session/
host_write_capture.rs

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
24/// Rolls back the staged audience blob files after a failed capture,
25/// folding any cleanup failure into the operation error it returns.
26pub 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
38/// Staging audience-move blobs into the spool needs the connected staging
39/// owner replication composes. The capture transaction names only this port;
40/// the returned rollback closure is consumed on failure.
41pub 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    /// Where this store's payload files are. Staging an audience blob move
59    /// resolves the Circle authority behind it, whose records name payloads.
60    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
224/// The rows a captured changeset removed, as `(table, row id)`.
225///
226/// A delete's changeset entry carries the row's old values, so its identity is
227/// still readable here even though the row is not: by the time anything runs
228/// after the host's SQL, the live table no longer holds it, and every resolver
229/// that walks from a row to its root answers `None`.
230fn 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    /// Everything the attached journal has recorded so far. A session accumulates,
247    /// so draining it again after more changes returns the whole write, merged —
248    /// which is what lets a write react to what it captured and be captured again.
249    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    /// Give every blob-bearing row an audience move drags along the move's stamp.
401    ///
402    /// The move re-seals those blobs under the destination audience, which mints a
403    /// new locator for content the row still binds at its old `_updated_at` — and a
404    /// row stamp binds one exact locator, so publishing would refuse the second
405    /// binding at that stamp. The stamp is the row's version, the re-seal is a new
406    /// version of it, and the move already stamps the component's routing at this
407    /// value: carrying the same stamp onto the rows makes the binding a fresh one by
408    /// construction. Rows the caller already stamped past the move are left alone —
409    /// their bindings are new stamps already, and lowering them would reorder the
410    /// caller's own writes.
411    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            // A root the host just deleted takes its cloud transition with it,
884            // in this same transaction. The rows it queued are read out of the
885            // journal rather than the tables, the row being what has just
886            // stopped being there to read.
887            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            // A move whose blobs get re-sealed owes those rows new stamps, and the
896            // rows have to reach the destination audience carrying them — so the
897            // moves are read first, the stamps land as more changes to this same
898            // transaction, and the journal that now holds all of it is what gets
899            // partitioned. Only the re-sealing materialization owes them: a blob
900            // locality transition moves its own bindings across the two phases it
901            // already runs, and restamping under it would rewrite rows it is
902            // mid-flight over.
903            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            // A capture that partitioned into nothing said nothing this store
953            // has any use for: no partition to publish, no local partition to
954            // put back over a canonical replay, no audience move, and so no
955            // blob fact either — every one of those is read out of the
956            // partitions and moves. Journalling it anyway ties the store's
957            // growth to how often the host opens a write rather than to what
958            // its writes contain: a host that persists once a second into its
959            // own untracked tables leaves a row, a changeset payload and a
960            // payload claim every second for as long as it runs, and every
961            // replay afterwards loads each row as a journal step that applies
962            // nothing.
963            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}