Skip to main content

coven_database/store/store_session/
snapshot_image.rs

1use std::path::{Path, PathBuf};
2
3use rusqlite::{Connection, OptionalExtension};
4use tracing::info;
5
6use crate::*;
7use coven_protocol::synced_schema::SyncedTable;
8
9use super::*;
10
11pub struct CreatedSnapshot {
12    db_image: SnapshotDatabaseImage,
13    blobs: Vec<SnapshotBlobFact>,
14}
15
16impl CreatedSnapshot {
17    pub fn new(db_image: SnapshotDatabaseImage, blobs: Vec<SnapshotBlobFact>) -> Self {
18        Self { db_image, blobs }
19    }
20
21    pub fn blobs(&self) -> &[SnapshotBlobFact] {
22        &self.blobs
23    }
24
25    pub async fn read_image(&self) -> Result<Vec<u8>, SnapshotImageError> {
26        self.db_image.read().await
27    }
28
29    pub fn into_parts(self) -> (SnapshotDatabaseImage, Vec<SnapshotBlobFact>) {
30        (self.db_image, self.blobs)
31    }
32
33    #[cfg(any(test, feature = "test-utils"))]
34    pub fn image_path_for_test(&self) -> &Path {
35        self.db_image.path()
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct SnapshotBlobFact {
41    pub fact: crate::StoreWriteBlobFact,
42    pub audience: SnapshotBlobAudience,
43}
44
45#[derive(Debug, Clone)]
46pub enum SnapshotBlobAudience {
47    Store,
48    Circle {
49        circle_id: coven_protocol::circle::CircleId,
50        control: crate::CirclePartitionControl,
51    },
52}
53
54#[derive(Debug, thiserror::Error)]
55pub enum SnapshotImageError {
56    #[error("IO error: {0}")]
57    Io(#[from] std::io::Error),
58    #[error("no synced tables registered; refusing to emit an all-cleared snapshot")]
59    NoSyncedTables,
60    #[error("failed to scope snapshot down to shareable data: {0}")]
61    Projection(String),
62    #[error("snapshot database: {0}")]
63    Database(#[source] Box<DbError>),
64    #[error("snapshot gate: {0}")]
65    Gate(#[from] crate::GateError),
66    #[error("snapshot row routing key: {0}")]
67    RowRoutingKey(#[from] coven_protocol::circle::RowRoutingKeyError),
68    #[error("snapshot SQLite: {0}")]
69    Sqlite(#[from] rusqlite::Error),
70    #[error("snapshot remote object: {0}")]
71    RemoteObject(#[from] coven_protocol::remote_object::RemoteObjectRecordError),
72    #[error("snapshot blob declarations: {0}")]
73    BlobDecl(#[from] crate::BlobDeclError),
74    #[error("snapshot routing contract: {0}")]
75    RoutingContract(#[from] crate::SyncRoutingContractError),
76    #[error("snapshot projection {operation}: {source}")]
77    ProjectionSqlite {
78        operation: String,
79        #[source]
80        source: rusqlite::Error,
81    },
82    #[error("snapshot projection {operation}: {source}")]
83    ProjectionDatabase {
84        operation: String,
85        #[source]
86        source: Box<DbError>,
87    },
88    #[error("snapshot projection {operation}: {source}")]
89    ProjectionIo {
90        operation: String,
91        #[source]
92        source: std::io::Error,
93    },
94    #[error("snapshot projection {operation}: {source}")]
95    ProjectionPayloadStore {
96        operation: String,
97        #[source]
98        source: crate::PayloadStoreError,
99    },
100    #[error("snapshot blob {namespace}/{id} plaintext hash: {source}")]
101    BlobHash {
102        namespace: String,
103        id: String,
104        #[source]
105        source: coven_foundation::object_hash::InvalidObjectHash,
106    },
107    #[error(
108        "could not remove staged snapshot database {path}: {cleanup}",
109        path = .path.display()
110    )]
111    Cleanup { path: PathBuf, cleanup: String },
112    #[error(
113        "snapshot operation failed and staged database {path} could not be removed: {cleanup} \
114         (operation error: {cause})",
115        path = .path.display()
116    )]
117    CleanupAfterFailure {
118        path: PathBuf,
119        cleanup: String,
120        cause: Box<SnapshotImageError>,
121    },
122}
123
124impl From<DbError> for SnapshotImageError {
125    fn from(error: DbError) -> Self {
126        Self::Database(Box::new(error))
127    }
128}
129
130#[derive(Debug)]
131pub enum SnapshotImageOperationError<E> {
132    Operation(E),
133    Cleanup {
134        path: PathBuf,
135        cleanup: String,
136    },
137    CleanupAfterFailure {
138        path: PathBuf,
139        cleanup: String,
140        cause: E,
141    },
142}
143
144/// One uncommitted SQLite image and its sidecar files.
145///
146/// The path remains armed until the operation commits or consumes the image.
147/// Failures report cleanup failure instead of leaving a plaintext image behind.
148#[derive(Debug)]
149pub struct SnapshotDatabaseImage {
150    path: PathBuf,
151    armed: bool,
152}
153
154impl SnapshotDatabaseImage {
155    pub fn prepare(path: PathBuf) -> Result<Self, SnapshotImageError> {
156        let mut staged = Self { path, armed: true };
157        if let Err(cleanup) = staged.remove_files() {
158            staged.armed = false;
159            return Err(SnapshotImageError::Cleanup {
160                path: staged.path.clone(),
161                cleanup: cleanup.to_string(),
162            });
163        }
164        Ok(staged)
165    }
166
167    pub fn create(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
168        if let Some(parent) = path.parent() {
169            std::fs::create_dir_all(parent)?;
170        }
171        Self { path, armed: false }.write_new(plaintext)
172    }
173
174    pub fn replace(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
175        Self::prepare(path)?.write_new(plaintext)
176    }
177
178    fn prepare_snapshot(temp_dir: &Path) -> Result<Self, SnapshotImageError> {
179        Self::prepare(temp_dir.join("snapshot.db"))
180    }
181
182    pub(super) fn capture_on(
183        self,
184        connection: &rusqlite::Connection,
185        store_dir: &coven_foundation::store_dir::StoreDir,
186        root: &coven_protocol::store_commit::StoreRootRef,
187        tables: &[SyncedTable],
188        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
189        audience: &coven_protocol::circle::Audience,
190    ) -> Result<CreatedSnapshot, SnapshotImageError> {
191        if tables.is_empty() {
192            return self.finish(Err(SnapshotImageError::NoSyncedTables));
193        }
194        let gates = match crate::Gates::from_tables(connection, tables) {
195            Ok(gates) => gates,
196            Err(error) => {
197                return self.finish(Err(SnapshotImageError::from(error)));
198            }
199        };
200        let routing_key = if gates.has_scoped_graph() {
201            let encryption = match routing_encryption {
202                Some(encryption) => encryption,
203                None => {
204                    return self.finish(Err(SnapshotImageError::Projection(
205                        "scoped snapshot creation requires Store routing encryption".to_string(),
206                    )));
207                }
208            };
209            match coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash) {
210                Ok(routing_key) => Some(routing_key),
211                Err(error) => {
212                    return self.finish(Err(SnapshotImageError::from(error)));
213                }
214            }
215        } else {
216            None
217        };
218
219        let source_image = match crate::connection_io::serialize_database_image(connection) {
220            Ok(image) => image,
221            Err(error) => {
222                return self.finish(Err(SnapshotImageError::from(error)));
223            }
224        };
225        let mut snapshot = match Connection::open_in_memory().map_err(DbError::from) {
226            Ok(snapshot) => snapshot,
227            Err(error) => {
228                return self.finish(Err(SnapshotImageError::from(error)));
229            }
230        };
231        if let Err(error) =
232            crate::connection_io::deserialize_database_image_into(&mut snapshot, &source_image)
233        {
234            return self.finish(Err(SnapshotImageError::from(error)));
235        }
236        if let Err(error) = Self::project(
237            &mut snapshot,
238            store_dir,
239            root,
240            tables,
241            routing_key.as_ref(),
242            audience,
243        ) {
244            return self.finish(Err(error));
245        }
246        let blobs = match Self::blob_facts(connection, &snapshot, tables) {
247            Ok(blobs) => blobs,
248            Err(error) => return self.finish(Err(error)),
249        };
250        if matches!(audience, coven_protocol::circle::Audience::Circle(_)) {
251            if let Err(error) = Self::strip_circle_transport_state(&mut snapshot) {
252                return self.finish(Err(error));
253            }
254        }
255
256        let image = match crate::connection_io::serialize_database_image(&snapshot) {
257            Ok(image) => image,
258            Err(error) => {
259                return self.finish(Err(SnapshotImageError::from(error)));
260            }
261        };
262        drop(snapshot);
263        let snapshot = self.write_new(&image)?;
264
265        let plaintext_size = match std::fs::metadata(snapshot.path()) {
266            Ok(metadata) => metadata.len(),
267            Err(error) => return snapshot.finish(Err(SnapshotImageError::Io(error))),
268        };
269        info!(plaintext_size, "created snapshot");
270        Ok(CreatedSnapshot::new(snapshot, blobs))
271    }
272
273    fn write_new(mut self, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
274        let mut file = match std::fs::OpenOptions::new()
275            .write(true)
276            .create_new(true)
277            .open(&self.path)
278        {
279            Ok(file) => file,
280            Err(error) => {
281                self.armed = false;
282                return Err(SnapshotImageError::Io(error));
283            }
284        };
285        self.armed = true;
286        if let Err(error) = std::io::Write::write_all(&mut file, plaintext) {
287            drop(file);
288            return self.finish(Err(SnapshotImageError::Io(error)));
289        }
290        drop(file);
291        Ok(self)
292    }
293
294    pub fn path(&self) -> &Path {
295        &self.path
296    }
297
298    pub async fn read(&self) -> Result<Vec<u8>, SnapshotImageError> {
299        tokio::fs::read(&self.path)
300            .await
301            .map_err(|error| SnapshotImageError::ProjectionIo {
302                operation: format!("read staged snapshot database {}", self.path.display()),
303                source: error,
304            })
305    }
306
307    pub fn read_and_discard(self) -> Result<Vec<u8>, SnapshotImageError> {
308        let outcome = std::fs::read(&self.path).map_err(SnapshotImageError::Io);
309        self.finish(outcome)
310    }
311
312    pub fn canonicalize(mut self) -> Result<Self, SnapshotImageError> {
313        match std::fs::canonicalize(&self.path) {
314            Ok(path) => {
315                self.path = path;
316                Ok(self)
317            }
318            Err(error) => self.finish(Err(SnapshotImageError::Io(error))),
319        }
320    }
321
322    pub fn finish<T>(
323        self,
324        outcome: Result<T, SnapshotImageError>,
325    ) -> Result<T, SnapshotImageError> {
326        match self.finish_operation(outcome) {
327            Ok(value) => Ok(value),
328            Err(SnapshotImageOperationError::Operation(cause)) => Err(cause),
329            Err(SnapshotImageOperationError::Cleanup { path, cleanup }) => {
330                Err(SnapshotImageError::Cleanup { path, cleanup })
331            }
332            Err(SnapshotImageOperationError::CleanupAfterFailure {
333                path,
334                cleanup,
335                cause,
336            }) => Err(SnapshotImageError::CleanupAfterFailure {
337                path,
338                cleanup,
339                cause: Box::new(cause),
340            }),
341        }
342    }
343
344    pub fn finish_operation<T, E>(
345        mut self,
346        outcome: Result<T, E>,
347    ) -> Result<T, SnapshotImageOperationError<E>> {
348        let cleanup = self.remove_files();
349        self.armed = false;
350        match (outcome, cleanup) {
351            (Ok(value), Ok(())) => Ok(value),
352            (Err(cause), Ok(())) => Err(SnapshotImageOperationError::Operation(cause)),
353            (Ok(_), Err(cleanup)) => Err(SnapshotImageOperationError::Cleanup {
354                path: self.path.clone(),
355                cleanup: cleanup.to_string(),
356            }),
357            (Err(cause), Err(cleanup)) => Err(SnapshotImageOperationError::CleanupAfterFailure {
358                path: self.path.clone(),
359                cleanup: cleanup.to_string(),
360                cause,
361            }),
362        }
363    }
364
365    pub fn commit(mut self) -> PathBuf {
366        self.armed = false;
367        std::mem::take(&mut self.path)
368    }
369
370    fn project(
371        connection: &mut Connection,
372        store_dir: &coven_foundation::store_dir::StoreDir,
373        root: &coven_protocol::store_commit::StoreRootRef,
374        synced: &[SyncedTable],
375        routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
376        audience: &coven_protocol::circle::Audience,
377    ) -> Result<(), SnapshotImageError> {
378        let gates =
379            crate::Gates::from_tables(connection, synced).map_err(SnapshotImageError::from)?;
380        if gates.has_scoped_graph() && routing_key.is_none() {
381            return Err(SnapshotImageError::Projection(
382                "scoped snapshot projection requires a row-routing key".to_string(),
383            ));
384        }
385        let transaction = connection
386            .unchecked_transaction()
387            .map_err(SnapshotImageError::from)?;
388        transaction
389            .pragma_update(None, "defer_foreign_keys", "ON")
390            .map_err(SnapshotImageError::from)?;
391        let coverage =
392            crate::store::materialized_commit_index::materialized_frontier_on(&transaction, None)
393                .map_err(SnapshotImageError::from)?;
394        let cleared_materialization_tables = ["materialized_commits"];
395        for table in cleared_materialization_tables {
396            transaction
397                .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(table)))
398                .map_err(|error| SnapshotImageError::ProjectionSqlite {
399                    operation: format!("clear {table}"),
400                    source: error,
401                })?;
402        }
403        if matches!(audience, coven_protocol::circle::Audience::Store) {
404            let records =
405                crate::store::store_session::StoreTransaction::new(&transaction, store_dir);
406            let mut authority = super::VerifiedStoreAuthority::default();
407            records
408                .retain_snapshot_replay_inputs(&mut authority, root)
409                .map_err(SnapshotImageError::from)?;
410            records
411                .retain_snapshot_device_states(&mut authority, root, coverage)
412                .map_err(SnapshotImageError::from)?;
413        }
414        let preserved_non_synced_tables = match audience {
415            coven_protocol::circle::Audience::Store => SNAPSHOT_PRESERVED_NON_SYNCED_TABLES,
416            coven_protocol::circle::Audience::Circle(_) => CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES,
417            coven_protocol::circle::Audience::Local => {
418                return Err(SnapshotImageError::Projection(
419                    "Local rows cannot enter a snapshot".to_string(),
420                ));
421            }
422        };
423        for table in crate::user_table_names(connection).map_err(|error| {
424            SnapshotImageError::ProjectionSqlite {
425                operation: "list user tables".to_string(),
426                source: error,
427            }
428        })? {
429            if synced.iter().any(|synced| synced.name() == table)
430                || preserved_non_synced_tables.contains(&table.as_str())
431                || cleared_materialization_tables.contains(&table.as_str())
432            {
433                continue;
434            }
435            transaction
436                .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(&table)))
437                .map_err(|error| SnapshotImageError::ProjectionSqlite {
438                    operation: format!("clear {table}"),
439                    source: error,
440                })?;
441        }
442
443        match audience {
444            coven_protocol::circle::Audience::Store => gates
445                .delete_gated_false(&transaction)
446                .map_err(SnapshotImageError::from)?,
447            coven_protocol::circle::Audience::Circle(_) => {
448                crate::retain_snapshot_audience_rows(&transaction, &gates, audience)
449                    .map_err(SnapshotImageError::from)?;
450            }
451            coven_protocol::circle::Audience::Local => {
452                return Err(SnapshotImageError::Projection(
453                    "Local rows cannot enter a snapshot".to_string(),
454                ));
455            }
456        }
457        if let Some(routing_key) = routing_key {
458            crate::prune_private_routes_without_rows(&transaction, &gates)
459                .map_err(SnapshotImageError::from)?;
460            crate::validate_snapshot_routing_state(&transaction, &gates, routing_key, audience)
461                .map_err(SnapshotImageError::from)?;
462        }
463
464        scope_authenticated_blob_graph(&transaction, synced)?;
465        transaction.commit().map_err(SnapshotImageError::from)?;
466        if matches!(audience, coven_protocol::circle::Audience::Store) {
467            connection.execute_batch("VACUUM").map_err(|error| {
468                SnapshotImageError::ProjectionSqlite {
469                    operation: "vacuum".to_string(),
470                    source: error,
471                }
472            })?;
473        }
474        Ok(())
475    }
476
477    fn strip_circle_transport_state(connection: &mut Connection) -> Result<(), SnapshotImageError> {
478        connection
479            .pragma_update(None, "foreign_keys", "ON")
480            .map_err(SnapshotImageError::from)?;
481        let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
482        // These rows are the copy's, describing the spool of the device that
483        // built it, so they are deleted without releasing any payload claim.
484        transaction
485            .execute_batch(
486                "DELETE FROM row_blob_locators;
487                 DELETE FROM blob_locators;
488                 DELETE FROM retained_replay_objects;
489                 DELETE FROM remote_objects;
490                 DELETE FROM retained_merge_materializations;",
491            )
492            .map_err(|error| SnapshotImageError::ProjectionSqlite {
493                operation: "strip Circle snapshot transport state".to_string(),
494                source: error,
495            })?;
496        transaction.commit().map_err(SnapshotImageError::from)?;
497        connection.execute_batch("VACUUM").map_err(|error| {
498            SnapshotImageError::ProjectionSqlite {
499                operation: "vacuum Circle snapshot transport projection".to_string(),
500                source: error,
501            }
502        })?;
503        Ok(())
504    }
505
506    pub fn install_blob_graph(
507        self,
508        blobs: &[crate::PreparedSnapshotBlob],
509    ) -> Result<Self, SnapshotImageError> {
510        let result = (|| {
511            let source = std::fs::read(self.path()).map_err(SnapshotImageError::Io)?;
512            let mut connection = Connection::open_in_memory()
513                .map_err(DbError::from)
514                .map_err(SnapshotImageError::from)?;
515            crate::connection_io::deserialize_database_image_into(&mut connection, &source)
516                .map_err(SnapshotImageError::from)?;
517            connection
518                .pragma_update(None, "foreign_keys", "ON")
519                .map_err(SnapshotImageError::from)?;
520            let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
521            for blob in blobs {
522                blob.remote.validate().map_err(SnapshotImageError::from)?;
523                if blob.bindings.is_empty()
524                    || blob
525                        .bindings
526                        .iter()
527                        .any(|binding| binding.blob().object() != blob.remote.object())
528                {
529                    return Err(SnapshotImageError::Projection(
530                        "snapshot blob binding differs from its remote object".to_string(),
531                    ));
532                }
533                crate::install_snapshot_blob_plan_on(&transaction, blob).map_err(|error| {
534                    SnapshotImageError::ProjectionDatabase {
535                        operation: "install snapshot blob".to_string(),
536                        source: Box::new(error),
537                    }
538                })?;
539            }
540            transaction.commit().map_err(SnapshotImageError::from)?;
541            connection.execute_batch("VACUUM").map_err(|error| {
542                SnapshotImageError::ProjectionSqlite {
543                    operation: "vacuum snapshot closure".to_string(),
544                    source: error,
545                }
546            })?;
547            let image = crate::connection_io::serialize_database_image(&connection)
548                .map_err(SnapshotImageError::from)?;
549            connection
550                .close()
551                .map_err(|(_, error)| SnapshotImageError::ProjectionSqlite {
552                    operation: "close snapshot closure image".to_string(),
553                    source: error,
554                })?;
555            let mut file = std::fs::OpenOptions::new()
556                .write(true)
557                .truncate(true)
558                .open(self.path())
559                .map_err(SnapshotImageError::Io)?;
560            std::io::Write::write_all(&mut file, &image).map_err(SnapshotImageError::Io)?;
561            Ok(())
562        })();
563        match result {
564            Ok(()) => Ok(self),
565            Err(error) => self.finish(Err(error)),
566        }
567    }
568
569    fn blob_facts(
570        live: &Connection,
571        snapshot: &Connection,
572        tables: &[SyncedTable],
573    ) -> Result<Vec<SnapshotBlobFact>, SnapshotImageError> {
574        let declarations =
575            crate::BlobDecls::from_tables(snapshot, tables).map_err(SnapshotImageError::from)?;
576        let publications = declarations
577            .publication_blobs_in_db(snapshot)
578            .map_err(SnapshotImageError::from)?;
579        let gates = crate::Gates::from_tables(live, tables).map_err(SnapshotImageError::from)?;
580        let mut facts = Vec::with_capacity(publications.len());
581        for publication in publications {
582            let plaintext_hash = publication.plaintext_hash.parse().map_err(|error| {
583                SnapshotImageError::BlobHash {
584                    namespace: publication.blob.namespace.clone(),
585                    id: publication.blob.id.clone(),
586                    source: error,
587                }
588            })?;
589            let external_path =
590                if publication.blob.provenance == coven_protocol::blob::Provenance::UserProvided {
591                    live.query_row(
592                        "SELECT path FROM local_blob_refs
593                         WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
594                           AND row_stamp = ?4 AND namespace = ?5 AND blob_id = ?6",
595                        rusqlite::params![
596                            publication.table,
597                            publication.row_id,
598                            publication.column,
599                            publication.row_stamp,
600                            publication.blob.namespace,
601                            publication.blob.id,
602                        ],
603                        |row| row.get::<_, String>(0),
604                    )
605                    .optional()
606                    .map_err(SnapshotImageError::from)?
607                    .map(PathBuf::from)
608                } else {
609                    None
610                };
611            let previous = crate::previous_row_blob_for_write_on(
612                snapshot,
613                &publication.table,
614                &publication.row_id,
615                &publication.row_stamp,
616                &publication.column,
617                &publication.blob,
618                publication.plaintext_size,
619                plaintext_hash,
620            )
621            .map_err(SnapshotImageError::from)?;
622            let audience = match crate::live_row_audience(
623                live,
624                &gates,
625                &publication.table,
626                &publication.row_id,
627            )
628            .map_err(SnapshotImageError::from)?
629            {
630                coven_protocol::circle::Audience::Store => SnapshotBlobAudience::Store,
631                coven_protocol::circle::Audience::Circle(circle_id) => {
632                    SnapshotBlobAudience::Circle {
633                        circle_id,
634                        control: crate::active_circle_control(live, circle_id)
635                            .map_err(SnapshotImageError::from)?,
636                    }
637                }
638                coven_protocol::circle::Audience::Local => {
639                    return Err(SnapshotImageError::Projection(format!(
640                        "scoped snapshot retains local blob row {:?}/{:?}",
641                        publication.table, publication.row_id
642                    )));
643                }
644            };
645            facts.push(SnapshotBlobFact {
646                fact: crate::StoreWriteBlobFact {
647                    table: publication.table,
648                    row_id: publication.row_id,
649                    row_stamp: publication.row_stamp,
650                    column: publication.column,
651                    blob: publication.blob,
652                    plaintext_size: publication.plaintext_size,
653                    plaintext_hash,
654                    external_path,
655                    previous,
656                    audience_move: None,
657                },
658                audience,
659            });
660        }
661        Ok(facts)
662    }
663
664    fn remove_files(&self) -> std::io::Result<()> {
665        for candidate in [
666            self.path.clone(),
667            PathBuf::from(format!("{}-wal", self.path.display())),
668            PathBuf::from(format!("{}-shm", self.path.display())),
669        ] {
670            match std::fs::remove_file(candidate) {
671                Ok(()) => {}
672                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
673                Err(error) => return Err(error),
674            }
675        }
676        Ok(())
677    }
678}
679
680impl Drop for SnapshotDatabaseImage {
681    fn drop(&mut self) {
682        if !self.armed {
683            return;
684        }
685        if let Err(error) = self.remove_files() {
686            tracing::warn!(
687                path = %self.path.display(),
688                %error,
689                "could not remove abandoned staged snapshot database"
690            );
691        }
692    }
693}
694
695impl StoreSession<'_> {
696    fn capture_snapshot_cut(
697        &self,
698        root: &coven_protocol::store_commit::StoreRootRef,
699        temp_dir: &Path,
700        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
701        audience: coven_protocol::circle::Audience,
702    ) -> Result<
703        (
704            CreatedSnapshot,
705            coven_protocol::store_commit::CommitFrontier,
706        ),
707        DbError,
708    > {
709        let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
710        require_no_unpublished_store_writes(self.conn)?;
711        let snapshot = SnapshotDatabaseImage::prepare_snapshot(temp_dir)
712            .and_then(|image| {
713                records.capture_snapshot(
714                    image,
715                    root,
716                    self.synced_tables,
717                    routing_encryption,
718                    &audience,
719                )
720            })
721            .map_err(snapshot_image_db_error)?;
722        let coverage = coven_protocol::store_commit::CommitFrontier::from_refs(
723            crate::store::materialized_commit_index::materialized_frontier_on(self.conn, None)?,
724        )
725        .map_err(|error| DbError::context("snapshot coverage", error))?;
726        Ok((snapshot, coverage))
727    }
728
729    /// Reconstruct the Store as of `cut` and serialize it as a replay baseline,
730    /// alongside the write-journal prefix the image now states.
731    ///
732    /// The live database is left exactly as it was: the projection is built
733    /// inside a transaction that is rolled back, the same way a Circle close
734    /// captures its cutoff image. What comes back is a database whose frontier
735    /// is `cut` — checked, not assumed — which is the one property a baseline
736    /// image must have, because replay applies the retained commits the cut
737    /// does not cover on top of it.
738    ///
739    /// It also folds in the local partitions of the writes settled at `cut`.
740    /// A local partition is stated nowhere else — no commit carries one, and an
741    /// image projected for an audience may not — so without this the journal is
742    /// the durable home of every local row a device has ever written, replayed
743    /// in full on every rebuild and never shorter. Folding them in is what lets
744    /// the advance adopting this image delete them, and the returned write ids
745    /// are exactly what it may delete.
746    pub(super) fn capture_replay_baseline_at_cut(
747        &mut self,
748        root: &coven_protocol::store_commit::StoreRootRef,
749        cut: &coven_protocol::store_commit::CommitFrontier,
750        current_cut: &coven_protocol::store_commit::CommitFrontier,
751        snapshot_hash: crate::ObjectHash,
752        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
753    ) -> Result<(Vec<u8>, Vec<crate::SettledStoreWrite>), DbError> {
754        let routing_key = if self.gates.has_scoped_graph() {
755            let encryption = routing_encryption.ok_or_else(|| {
756                DbError::Message(
757                    "scoped replay baseline capture requires Store routing encryption".to_string(),
758                )
759            })?;
760            Some(
761                coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash)
762                    .map_err(DbError::from)?,
763            )
764        } else {
765            None
766        };
767        let folded = crate::StoreDatabase::settled_store_write_prefix_on(
768            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
769            cut,
770        )?;
771        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
772        let records =
773            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir);
774        let current_replay = self
775            .verified_store_authority
776            .replay_projection_result_for_root_on(
777                records,
778                root,
779                self.blob_decls,
780                self.gates,
781                self.synced_tables,
782                routing_key.as_ref(),
783                current_cut,
784            )?;
785        if current_replay.materialized_frontier()? != *current_cut {
786            return Err(DbError::Message(
787                "replay retirement proof does not cover the current Store frontier".to_string(),
788            ));
789        }
790        let mut crossed_cut = false;
791        for reference in current_replay.applied_order() {
792            if cut.covers_commit(reference) {
793                if crossed_cut {
794                    return Err(DbError::ReplayRetirementCutNotPrefix);
795                }
796            } else {
797                crossed_cut = true;
798            }
799        }
800        let replay = records.replay_projection_with_authority(
801            self.verified_store_authority,
802            root,
803            self.blob_decls,
804            self.gates,
805            self.synced_tables,
806            routing_key.as_ref(),
807            &std::collections::BTreeSet::new(),
808            Some(cut),
809            crate::ReplayJournal::Folded(&folded),
810            coven_protocol::membership::LocalStoreMembership::Current,
811        )?;
812        transaction.rollback().map_err(DbError::from)?;
813        let replay_frontier = replay.materialized_frontier()?;
814        if replay_frontier != *cut {
815            return Err(DbError::Message(
816                "retained replay baseline cut is not an exact Store frontier".to_string(),
817            ));
818        }
819        Ok((
820            replay.capture_replay_baseline(root, cut, snapshot_hash)?,
821            folded,
822        ))
823    }
824
825    #[allow(clippy::too_many_arguments)]
826    fn capture_circle_snapshot_at_cutoff(
827        &mut self,
828        root: &coven_protocol::store_commit::StoreRootRef,
829        temp_dir: &Path,
830        routing_encryption: &coven_keys::encryption::EncryptionService,
831        routing_key: &coven_protocol::circle::RowRoutingKey,
832        circle_id: coven_protocol::circle::CircleId,
833        cutoff: &coven_protocol::store_commit::CommitFrontier,
834    ) -> Result<CreatedSnapshot, DbError> {
835        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
836        let replay =
837            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
838                .replay_projection_with_authority(
839                    self.verified_store_authority,
840                    root,
841                    self.blob_decls,
842                    self.gates,
843                    self.synced_tables,
844                    Some(routing_key),
845                    &std::collections::BTreeSet::new(),
846                    Some(cutoff),
847                    crate::ReplayJournal::Omit,
848                    coven_protocol::membership::LocalStoreMembership::Current,
849                )?;
850        transaction.rollback().map_err(DbError::from)?;
851        let replay_frontier = replay.materialized_frontier()?;
852        if replay_frontier != *cutoff {
853            return Err(DbError::Message(
854                "Circle close cutoff is not an exact retained Store frontier".to_string(),
855            ));
856        }
857        SnapshotDatabaseImage::prepare_snapshot(temp_dir)
858            .and_then(|image| {
859                replay.capture_snapshot(
860                    image,
861                    root,
862                    self.synced_tables,
863                    Some(routing_encryption),
864                    &coven_protocol::circle::Audience::Circle(circle_id),
865                )
866            })
867            .map_err(snapshot_image_db_error)
868    }
869
870    #[cfg(any(test, feature = "test-utils"))]
871    fn capture_snapshot_image_for_test(
872        &self,
873        root: &coven_protocol::store_commit::StoreRootRef,
874        temp_dir: &Path,
875        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
876        audience: coven_protocol::circle::Audience,
877    ) -> Result<Vec<u8>, DbError> {
878        SnapshotDatabaseImage::prepare_snapshot(temp_dir)
879            .and_then(|image| {
880                crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
881                    .capture_snapshot(
882                        image,
883                        root,
884                        self.synced_tables,
885                        routing_encryption,
886                        &audience,
887                    )
888            })
889            .and_then(|snapshot| snapshot.into_parts().0.read_and_discard())
890            .map_err(snapshot_image_db_error)
891    }
892}
893
894impl StoreDatabase {
895    pub async fn capture_store_snapshot_cut(
896        &self,
897        root: coven_protocol::store_commit::StoreRootRef,
898        temp_dir: PathBuf,
899        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
900    ) -> Result<
901        (
902            CreatedSnapshot,
903            coven_protocol::store_commit::CommitFrontier,
904        ),
905        DbError,
906    > {
907        self.call_store(move |session| {
908            session.capture_snapshot_cut(
909                &root,
910                &temp_dir,
911                routing_encryption.as_ref(),
912                coven_protocol::circle::Audience::Store,
913            )
914        })
915        .await
916    }
917
918    pub async fn capture_circle_snapshot_cut(
919        &self,
920        root: coven_protocol::store_commit::StoreRootRef,
921        temp_dir: PathBuf,
922        routing_encryption: coven_keys::encryption::EncryptionService,
923        circle_id: coven_protocol::circle::CircleId,
924    ) -> Result<
925        (
926            CreatedSnapshot,
927            coven_protocol::store_commit::CommitFrontier,
928        ),
929        DbError,
930    > {
931        self.call_store(move |session| {
932            session.capture_snapshot_cut(
933                &root,
934                &temp_dir,
935                Some(&routing_encryption),
936                coven_protocol::circle::Audience::Circle(circle_id),
937            )
938        })
939        .await
940    }
941
942    #[allow(clippy::too_many_arguments)]
943    pub async fn capture_circle_snapshot_at_cutoff(
944        &self,
945        root: coven_protocol::store_commit::StoreRootRef,
946        temp_dir: PathBuf,
947        routing_encryption: coven_keys::encryption::EncryptionService,
948        routing_key: coven_protocol::circle::RowRoutingKey,
949        circle_id: coven_protocol::circle::CircleId,
950        cutoff: coven_protocol::store_commit::CommitFrontier,
951    ) -> Result<CreatedSnapshot, DbError> {
952        self.call_store(move |session| {
953            session.capture_circle_snapshot_at_cutoff(
954                &root,
955                &temp_dir,
956                &routing_encryption,
957                &routing_key,
958                circle_id,
959                &cutoff,
960            )
961        })
962        .await
963    }
964
965    pub async fn verify_circle_bootstrap_image(
966        &self,
967        image: Vec<u8>,
968        reference: coven_protocol::circle::CircleBootstrapRef,
969        circle_id: coven_protocol::circle::CircleId,
970        routing_key: Option<coven_protocol::circle::RowRoutingKey>,
971    ) -> Result<Vec<u8>, SnapshotImageError> {
972        self.call_store(move |session| {
973            let verification = verify_circle_bootstrap_image(
974                &image,
975                &reference,
976                circle_id,
977                session.synced_tables,
978                routing_key.as_ref(),
979            );
980            Ok(verification.map(|()| image))
981        })
982        .await
983        .map_err(SnapshotImageError::from)?
984    }
985
986    #[cfg(any(test, feature = "test-utils"))]
987    pub async fn capture_snapshot_image_for_test(
988        &self,
989        root: coven_protocol::store_commit::StoreRootRef,
990        temp_dir: PathBuf,
991        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
992    ) -> Result<Vec<u8>, DbError> {
993        self.call_store(move |session| {
994            session.capture_snapshot_image_for_test(
995                &root,
996                &temp_dir,
997                routing_encryption.as_ref(),
998                coven_protocol::circle::Audience::Store,
999            )
1000        })
1001        .await
1002    }
1003
1004    #[cfg(any(test, feature = "test-utils"))]
1005    pub async fn capture_circle_snapshot_image_for_test(
1006        &self,
1007        root: coven_protocol::store_commit::StoreRootRef,
1008        temp_dir: PathBuf,
1009        routing_encryption: coven_keys::encryption::EncryptionService,
1010        circle_id: coven_protocol::circle::CircleId,
1011    ) -> Result<Vec<u8>, DbError> {
1012        self.call_store(move |session| {
1013            session.capture_snapshot_image_for_test(
1014                &root,
1015                &temp_dir,
1016                Some(&routing_encryption),
1017                coven_protocol::circle::Audience::Circle(circle_id),
1018            )
1019        })
1020        .await
1021    }
1022}
1023
1024pub(super) fn snapshot_image_db_error(error: SnapshotImageError) -> DbError {
1025    DbError::from(error)
1026}
1027
1028fn require_no_unpublished_store_writes(connection: &Connection) -> Result<(), DbError> {
1029    let pending: i64 = connection
1030        .query_row(
1031            "SELECT EXISTS(
1032                SELECT 1 FROM store_writes
1033                WHERE status != '\"local_only\"'
1034                  AND json_extract(status, '$.published') IS NULL
1035            )",
1036            [],
1037            |row| row.get(0),
1038        )
1039        .map_err(DbError::from)?;
1040    if pending != 0 {
1041        return Err(DbError::Message(
1042            "snapshot cut refused while unpublished Store writes exist".to_string(),
1043        ));
1044    }
1045    Ok(())
1046}
1047
1048const SNAPSHOT_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
1049    "_coven_audience",
1050    "_coven_row_routes",
1051    "remote_objects",
1052    "blob_locators",
1053    "row_blob_locators",
1054    "store_device_registration_activations",
1055    "store_device_state_snapshots",
1056    "store_device_states",
1057    "store_author_exclusion_activations",
1058    "circle_control_activations",
1059    "circle_access_cache",
1060    "circle_bootstrap_coverage",
1061    "circle_current_state",
1062    "retained_merge_materializations",
1063    "retained_replay_objects",
1064];
1065
1066const CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
1067    "_coven_audience",
1068    "_coven_row_routes",
1069    "remote_objects",
1070    "blob_locators",
1071    "row_blob_locators",
1072    "retained_merge_materializations",
1073    "retained_replay_objects",
1074];
1075
1076fn scope_authenticated_blob_graph(
1077    connection: &Connection,
1078    synced: &[SyncedTable],
1079) -> Result<(), SnapshotImageError> {
1080    connection
1081        .execute_batch(
1082            "CREATE TEMP TABLE snapshot_live_blob_bindings (
1083                 table_name TEXT NOT NULL,
1084                 row_id TEXT NOT NULL,
1085                 column_name TEXT NOT NULL,
1086                 row_stamp TEXT NOT NULL,
1087                 PRIMARY KEY (table_name, row_id, column_name, row_stamp)
1088             ) STRICT;",
1089        )
1090        .map_err(|error| SnapshotImageError::ProjectionSqlite {
1091            operation: "create blob scope".to_string(),
1092            source: error,
1093        })?;
1094    for table in synced {
1095        let Some(declaration) = table.blob() else {
1096            continue;
1097        };
1098        connection
1099            .execute(
1100                &format!(
1101                    "INSERT INTO snapshot_live_blob_bindings
1102                     (table_name, row_id, column_name, row_stamp)
1103                     SELECT ?1, id, ?2, _updated_at FROM {}
1104                     WHERE {} IS NOT NULL",
1105                    crate::quote_ident(table.name()),
1106                    crate::quote_ident(&declaration.id_column),
1107                ),
1108                rusqlite::params![table.name(), &declaration.id_column],
1109            )
1110            .map_err(|error| SnapshotImageError::ProjectionSqlite {
1111                operation: format!("collect live blob bindings for {:?}", table.name()),
1112                source: error,
1113            })?;
1114    }
1115    // As above: the projection prunes the copy's rows, never this device's
1116    // payload claims.
1117    connection
1118        .execute_batch(
1119            "DELETE FROM row_blob_locators
1120             WHERE NOT EXISTS (
1121                 SELECT 1 FROM snapshot_live_blob_bindings AS live
1122                 WHERE live.table_name = row_blob_locators.table_name
1123                   AND live.row_id = row_blob_locators.row_id
1124                   AND live.column_name = row_blob_locators.column_name
1125                   AND live.row_stamp = row_blob_locators.row_stamp
1126             );
1127             DELETE FROM blob_locators
1128             WHERE NOT EXISTS (
1129                 SELECT 1 FROM row_blob_locators AS binding
1130                 WHERE binding.remote_object_id = blob_locators.remote_object_id
1131             );
1132             DELETE FROM remote_objects
1133             WHERE NOT EXISTS (
1134                 SELECT 1 FROM blob_locators AS locator
1135                 WHERE locator.remote_object_id = remote_objects.object_id
1136             ) AND NOT EXISTS (
1137                 SELECT 1 FROM retained_replay_objects AS retained
1138                 WHERE retained.object_id = remote_objects.object_id
1139             );
1140             DROP TABLE snapshot_live_blob_bindings;",
1141        )
1142        .map_err(|error| SnapshotImageError::ProjectionSqlite {
1143            operation: "scope blob ownership graph".to_string(),
1144            source: error,
1145        })?;
1146    Ok(())
1147}
1148
1149pub(super) fn verify_circle_bootstrap_image(
1150    image: &[u8],
1151    reference: &coven_protocol::circle::CircleBootstrapRef,
1152    circle_id: coven_protocol::circle::CircleId,
1153    tables: &[SyncedTable],
1154    routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
1155) -> Result<(), SnapshotImageError> {
1156    if coven_protocol::store_commit::ObjectHash::digest(image) != reference.image.image_hash {
1157        return Err(SnapshotImageError::Projection(
1158            "Circle bootstrap image differs from its signed hash".to_string(),
1159        ));
1160    }
1161    let mut connection = Connection::open_in_memory()
1162        .map_err(DbError::from)
1163        .map_err(SnapshotImageError::from)?;
1164    crate::connection_io::deserialize_database_image_into(&mut connection, image)
1165        .map_err(SnapshotImageError::from)?;
1166    verify_circle_bootstrap_connection(&connection, reference, circle_id, tables, routing_key)
1167}
1168
1169pub(crate) fn verify_circle_bootstrap_connection(
1170    connection: &Connection,
1171    reference: &coven_protocol::circle::CircleBootstrapRef,
1172    circle_id: coven_protocol::circle::CircleId,
1173    tables: &[SyncedTable],
1174    routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
1175) -> Result<(), SnapshotImageError> {
1176    connection
1177        .pragma_update(None, "foreign_keys", "ON")
1178        .map_err(SnapshotImageError::from)?;
1179    let schema_version: u32 = connection
1180        .pragma_query_value(None, "user_version", |row| row.get(0))
1181        .map_err(SnapshotImageError::from)?;
1182    if schema_version != reference.schema_version {
1183        return Err(SnapshotImageError::Projection(format!(
1184            "Circle bootstrap schema is {schema_version}, expected {}",
1185            reference.schema_version
1186        )));
1187    }
1188    let routing_contract = crate::SyncRoutingContract::from_connection(connection, tables)
1189        .map_err(SnapshotImageError::from)?;
1190    if routing_contract.hash() != reference.sync_routing_hash {
1191        return Err(SnapshotImageError::Projection(
1192            "Circle bootstrap routing contract differs from its signed hash".to_string(),
1193        ));
1194    }
1195    let gates = crate::Gates::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1196    if gates.has_scoped_graph() {
1197        let routing_key = routing_key.ok_or_else(|| {
1198            SnapshotImageError::Projection(
1199                "scoped Circle bootstrap verification requires Store routing authentication"
1200                    .to_string(),
1201            )
1202        })?;
1203        crate::validate_snapshot_routing_state(
1204            connection,
1205            &gates,
1206            routing_key,
1207            &coven_protocol::circle::Audience::Circle(circle_id),
1208        )
1209        .map_err(SnapshotImageError::from)?;
1210    }
1211    let declarations =
1212        crate::BlobDecls::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1213    let rows = declarations
1214        .publication_blobs_in_db(connection)
1215        .map_err(SnapshotImageError::from)?;
1216    if rows.len() != reference.blobs.len() {
1217        return Err(SnapshotImageError::Projection(
1218            "Circle bootstrap blob closure does not exactly cover its image rows".to_string(),
1219        ));
1220    }
1221    for row in &rows {
1222        let mut matching = reference.blobs.iter().filter(|binding| {
1223            row.table == binding.table()
1224                && row.row_id == binding.row_id()
1225                && row.row_stamp == binding.row_stamp()
1226                && row.column == binding.column()
1227        });
1228        let binding = matching.next().ok_or_else(|| {
1229            SnapshotImageError::Projection(
1230                "Circle bootstrap image row has no exact signed blob binding".to_string(),
1231            )
1232        })?;
1233        if matching.next().is_some()
1234            || &row.blob != binding.blob()
1235            || row.plaintext_size != binding.plaintext_size()
1236            || row.plaintext_hash != binding.plaintext_hash().to_string()
1237            || !matches!(
1238                binding.authority(),
1239                coven_protocol::blob::RowBlobAuthority::Remote(
1240                    coven_protocol::audience_package::PackageAudience::Circle {
1241                        circle_id: binding_circle,
1242                        ..
1243                    }
1244                ) if *binding_circle == circle_id
1245            )
1246            || binding.stored().is_none()
1247        {
1248            return Err(SnapshotImageError::Projection(
1249                "Circle bootstrap blob closure differs from an exact image row".to_string(),
1250            ));
1251        }
1252    }
1253    for table in crate::user_table_names(connection).map_err(SnapshotImageError::from)? {
1254        if tables.iter().any(|synced| synced.name() == table)
1255            || matches!(table.as_str(), "_coven_audience" | "_coven_row_routes")
1256        {
1257            continue;
1258        }
1259        let count: i64 = connection
1260            .query_row(
1261                &format!("SELECT COUNT(*) FROM {}", crate::quote_ident(&table)),
1262                [],
1263                |row| row.get(0),
1264            )
1265            .map_err(SnapshotImageError::from)?;
1266        if count != 0 {
1267            return Err(SnapshotImageError::Projection(format!(
1268                "Circle bootstrap retains non-projection table {table:?}"
1269            )));
1270        }
1271    }
1272    Ok(())
1273}
1274
1275#[cfg(test)]
1276#[path = "snapshot_image_tests.rs"]
1277mod tests;