Skip to main content

coven_database/store/store_session/
host_write_capture.rs

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