Skip to main content

stateset_embedded/
maintenance.rs

1//! Backup, restore and structured export/import.
2//!
3//! Reached via [`Commerce::maintenance`](crate::Commerce::maintenance).
4//!
5//! Two complementary tools:
6//!
7//! - **Backup / restore** ([`Maintenance::backup_to`], [`Maintenance::restore_from`])
8//!   move a byte-exact, consistent image of the SQLite database. This is the
9//!   disaster-recovery path: identity, history and every table are preserved.
10//!   SQLite-backed instances only.
11//! - **Export / import** ([`Maintenance::export_to_file`], [`Maintenance::import_from_file`])
12//!   move business records as versioned JSON. Backend-independent, diffable,
13//!   and survives schema changes — but IDs are re-minted on import. See
14//!   [`stateset_db::portability`] for exactly what is and is not covered.
15//!
16//! # Example
17//!
18//! ```ignore
19//! use stateset_embedded::Commerce;
20//!
21//! let commerce = Commerce::new("./store.db")?;
22//!
23//! // Nightly backup.
24//! let report = commerce.maintenance().backup_to("./backups/store-nightly.db")?;
25//! println!("sha256 {}", report.manifest.checksum);
26//!
27//! // Portable snapshot.
28//! commerce.maintenance().export_to_file("./exports/store.json")?;
29//! # Ok::<(), stateset_embedded::CommerceError>(())
30//! ```
31
32use stateset_core::{CommerceError, Result};
33use stateset_db::Database;
34use std::path::Path;
35use std::sync::Arc;
36
37#[cfg(feature = "sqlite")]
38use stateset_db::SqliteDatabase;
39
40pub use stateset_db::portability::{
41    ConflictPolicy, ExportOptions, ExportReport, ImportOptions, ImportReport,
42};
43
44#[cfg(feature = "sqlite")]
45pub use stateset_db::maintenance::{BackupManifest, BackupReport, RestoreOptions, RestoreReport};
46
47/// Backup, restore, export and import operations.
48pub struct Maintenance {
49    db: Arc<dyn Database>,
50    #[cfg(feature = "sqlite")]
51    sqlite: Option<Arc<SqliteDatabase>>,
52    #[cfg(feature = "sqlite")]
53    sqlite_path: Option<String>,
54}
55
56impl std::fmt::Debug for Maintenance {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("Maintenance").finish_non_exhaustive()
59    }
60}
61
62impl Maintenance {
63    #[cfg(feature = "sqlite")]
64    pub(crate) fn new(
65        db: Arc<dyn Database>,
66        sqlite: Option<Arc<SqliteDatabase>>,
67        sqlite_path: Option<String>,
68    ) -> Self {
69        Self { db, sqlite, sqlite_path }
70    }
71
72    #[cfg(not(feature = "sqlite"))]
73    pub(crate) fn new(db: Arc<dyn Database>) -> Self {
74        Self { db }
75    }
76
77    /// Whether file-level backup and restore are available.
78    ///
79    /// False for PostgreSQL and externally-supplied databases — use
80    /// [`Self::export_to_file`] for those, or the backend's own tooling
81    /// (`pg_dump`).
82    #[must_use]
83    pub const fn supports_backup(&self) -> bool {
84        #[cfg(feature = "sqlite")]
85        {
86            self.sqlite.is_some()
87        }
88        #[cfg(not(feature = "sqlite"))]
89        {
90            false
91        }
92    }
93
94    #[cfg(feature = "sqlite")]
95    fn sqlite_handle(&self) -> Result<&Arc<SqliteDatabase>> {
96        self.sqlite.as_ref().ok_or_else(|| {
97            CommerceError::ValidationError(
98                "backup and restore require a SQLite-backed Commerce instance".to_owned(),
99            )
100        })
101    }
102
103    /// Take a consistent backup to `backup_path`, writing a sidecar manifest.
104    ///
105    /// Safe to call while other threads are writing: the backup is taken with
106    /// `VACUUM INTO` inside a read transaction.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error when the instance is not SQLite-backed, or when the
111    /// backup or its checksum verification fails.
112    #[cfg(feature = "sqlite")]
113    pub fn backup_to(&self, backup_path: impl AsRef<Path>) -> Result<BackupReport> {
114        let handle = self.sqlite_handle()?;
115        let conn = handle.conn()?;
116        let source = self.sqlite_path.clone().unwrap_or_else(|| ":memory:".to_owned());
117        stateset_db::maintenance::backup_to(&conn, source, backup_path).map_err(Into::into)
118    }
119
120    /// Restore a backup over `target_path`.
121    ///
122    /// This does **not** affect the currently-open database handle: restore
123    /// writes a file, and the process must reopen [`crate::Commerce`] against
124    /// the restored path to see the new data. Restoring over the path this
125    /// instance currently has open is rejected, because the open connection
126    /// pool would keep serving pages from the replaced file.
127    ///
128    /// # Errors
129    ///
130    /// See [`stateset_db::maintenance::restore_from`]. Also errors when
131    /// `target_path` is the database this instance has open.
132    #[cfg(feature = "sqlite")]
133    pub fn restore_from(
134        &self,
135        backup_path: impl AsRef<Path>,
136        target_path: impl AsRef<Path>,
137        options: &RestoreOptions,
138    ) -> Result<RestoreReport> {
139        let target = target_path.as_ref();
140        if let Some(open_path) = &self.sqlite_path {
141            let same = Path::new(open_path)
142                .canonicalize()
143                .ok()
144                .zip(target.canonicalize().ok())
145                .is_some_and(|(a, b)| a == b);
146            if same {
147                return Err(CommerceError::ValidationError(format!(
148                    "refusing to restore over '{open_path}', which this Commerce instance has \
149                     open; restore to a new path and reopen, or close this instance first"
150                )));
151            }
152        }
153        stateset_db::maintenance::restore_from(backup_path, target, options).map_err(Into::into)
154    }
155
156    /// Write a structured JSON export to `writer`.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if a repository read or the write fails.
161    pub fn export_all<W: std::io::Write>(
162        &self,
163        writer: &mut W,
164        options: &ExportOptions,
165    ) -> Result<ExportReport> {
166        stateset_db::portability::export_all(self.db.as_ref(), writer, options)
167    }
168
169    /// Write a structured JSON export to `path`, creating parent directories.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the file cannot be created or the export fails.
174    pub fn export_to_file(&self, path: impl AsRef<Path>) -> Result<ExportReport> {
175        self.export_to_file_with(path, &ExportOptions::default())
176    }
177
178    /// [`Self::export_to_file`] with explicit options.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if the file cannot be created or the export fails.
183    pub fn export_to_file_with(
184        &self,
185        path: impl AsRef<Path>,
186        options: &ExportOptions,
187    ) -> Result<ExportReport> {
188        let path = path.as_ref();
189        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
190            std::fs::create_dir_all(parent).map_err(|e| {
191                CommerceError::DatabaseError(format!(
192                    "cannot create export directory {}: {e}",
193                    parent.display()
194                ))
195            })?;
196        }
197        let file = std::fs::File::create(path).map_err(|e| {
198            CommerceError::DatabaseError(format!("cannot create {}: {e}", path.display()))
199        })?;
200        let mut writer = std::io::BufWriter::new(file);
201        self.export_all(&mut writer, options)
202    }
203
204    /// Read a structured JSON export from `reader` and replay it.
205    ///
206    /// # Errors
207    ///
208    /// See [`stateset_db::portability::import_all`].
209    pub fn import_all<R: std::io::Read>(
210        &self,
211        reader: &mut R,
212        options: &ImportOptions,
213    ) -> Result<ImportReport> {
214        stateset_db::portability::import_all(self.db.as_ref(), reader, options)
215    }
216
217    /// Read a structured JSON export from `path` and replay it.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the file cannot be read or the import fails.
222    pub fn import_from_file(
223        &self,
224        path: impl AsRef<Path>,
225        options: &ImportOptions,
226    ) -> Result<ImportReport> {
227        let path = path.as_ref();
228        let file = std::fs::File::open(path).map_err(|e| {
229            CommerceError::DatabaseError(format!("cannot open {}: {e}", path.display()))
230        })?;
231        let mut reader = std::io::BufReader::new(file);
232        self.import_all(&mut reader, options)
233    }
234
235    /// Domains the export covers, in export order.
236    #[must_use]
237    pub fn exportable_domains(&self) -> Vec<&'static str> {
238        stateset_db::portability::exportable_domains()
239    }
240
241    /// Domains the import can write.
242    #[must_use]
243    pub fn importable_domains(&self) -> Vec<&'static str> {
244        stateset_db::portability::importable_domains()
245    }
246}