From cdec1fb4519ad185ff2f2d1180044f7d1c5db1cf Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Wed, 19 Aug 2026 15:31:40 +0100 Subject: [PATCH 1/6] Initial outline of backup/restore --- src/db_service.rs | 7 +++++++ src/graphql/mod.rs | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/db_service.rs b/src/db_service.rs index 0445014..fb0a312 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -323,6 +323,13 @@ impl SqliteScanPathService { .collect()) } + pub async fn insert_configurations( + &self, + configs: &[InstrumentConfiguration], + ) -> Result<(), ()> { + todo!() + } + pub async fn next_scan_configuration( &self, instrument: &str, diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 707d42b..966df69 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -26,6 +26,7 @@ use async_graphql::{ }; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use auth::{AuthError, PolicyCheck}; +use axum::extract::State; use axum::http::StatusCode; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; @@ -67,7 +68,7 @@ pub async fn serve_graphql(opts: ServeOptions) { let schema = Schema::build(Query, Mutation, EmptySubscription) .extension(Tracing) .limit_directives(32) - .data(db) + .data(db.clone()) .data(directory_numtracker) .data(opts.policy.map(PolicyCheck::new)) .finish(); @@ -75,6 +76,8 @@ pub async fn serve_graphql(opts: ServeOptions) { // status check endpoint allows external processes to monitor status of server without // making graphql queries .route("/status", get(server_status)) + .route("/admin/export", get(export_handler)) + // .route("/admin/restore", post(restore_handler)) .route("/graphql", post(graphql_handler)) // make it obvious that /graphql isn't expected to work when visiting from a browser .route( @@ -88,6 +91,7 @@ pub async fn serve_graphql(opts: ServeOptions) { // Interactive graphiql playground .route("/graphiql", get(graphiql)) // Make it look less like something is broken when going to any other page + .with_state(db) .fallback(( StatusCode::NOT_FOUND, Html(include_str!("../../static/404.html")), @@ -102,6 +106,11 @@ pub async fn serve_graphql(opts: ServeOptions) { .expect("Can't serve graphql endpoint"); } +async fn export_handler(State(db): State) -> String { + let configs = db.all_configurations().await; + return format!("{configs:?}"); +} + async fn create_signal_handler() { let mut term = signal(SignalKind::terminate()).expect("Failed to create SIGTERM listener"); let mut int = signal(SignalKind::interrupt()).expect("Failed to create SIGINT listener"); From f0146a56f744005f5ec31ce1438e895e87ee57fb Mon Sep 17 00:00:00 2001 From: Shreelakshmi Iyengar Date: Wed, 2 Sep 2026 14:31:19 +0100 Subject: [PATCH 2/6] add transaction to insert configurations function --- src/db_service.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/db_service.rs b/src/db_service.rs index fb0a312..2b61344 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -326,8 +326,37 @@ impl SqliteScanPathService { pub async fn insert_configurations( &self, configs: &[InstrumentConfiguration], - ) -> Result<(), ()> { - todo!() + ) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + sqlx::query!("DelETE FROM instrument").execute(&mut tx).await?; + + for config in configs { + sqlx::query!( + "INSERT INTO instrument (name, scan_number, directory, scan, detector, tracker_file_extension) VALUES (?, ?, ?, ?, ?, ?)", + config.name, + config.scan_number, + config.directory, + config.scan, + config.detector, + config.tracker_file_extension + ) + .execute(&mut tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } +} + pub async fn next_scan_configuration( + &self, + instrument: &str, + current_high: Option, + ) -> Result { + let exp = current_high.unwrap_or(0); + query_as!( + DbInstrumentConfig, } pub async fn next_scan_configuration( From 4eaa682b74e672c056aea5537334dd8f8573ad31 Mon Sep 17 00:00:00 2001 From: Shreelakshmi Iyengar Date: Wed, 2 Sep 2026 16:25:50 +0100 Subject: [PATCH 3/6] add serialisable data transfer object --- src/db_service.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/graphql/mod.rs | 2 +- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/db_service.rs b/src/db_service.rs index 2b61344..fe5bc0e 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use serde::{Deserialize, Serialize}; use std::fmt; use std::marker::PhantomData; use std::path::Path; @@ -348,7 +349,7 @@ impl SqliteScanPathService { tx.commit().await?; Ok(()) } -} + pub async fn next_scan_configuration( &self, instrument: &str, @@ -406,6 +407,8 @@ impl fmt::Debug for SqliteScanPathService { mod error { use derive_more::{Display, Error, From}; +use crate::db_service::InstrumentConfiguration; + #[derive(Debug, Display, Error, From)] pub enum ConfigurationError { #[display("No configuration available for instrument {_0:?}")] @@ -428,6 +431,46 @@ mod error { Self::MissingField(value.into()) } } + + #[derive(Debug, Serialize, Deserialize)] // Adding DTO (Data Transfer Object) for serialisation and deserialisation of instrument configuration data + pub struct InstrumentConfigurationData { + pub name: String, + pub scan_number: u32, + pub directory: String, + pub scan: String, + pub detector: String, + pub tracker_file_extension: Option, + } + + + impl From for Instrument ConfigurationData { + fn from(config: InstrumentConfiguration) -> Self { + Self { + name: config.name, + scan_number: config.scan_number, + directory: config.directory.to_string(), + scan: config.scan.to_string(), + detector: config.detector.to_string(), + tracker_file_extension: config.tracker_file_extension, + } + } + } + impl From for Instrument Configuration { + fn from(data: InstrumentConfigurationData) -> Self { + Self { + name:data.name, + scan_number:data.scan_number, + directory:data.directory.to_string(), + scan:data.scan.to_string(), + detector:data.detector.to_string(), + tracker_file_extension:data.tracker_file_extension, + } + } + } + + + + } #[cfg(test)] diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 966df69..2815c33 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -107,7 +107,7 @@ pub async fn serve_graphql(opts: ServeOptions) { } async fn export_handler(State(db): State) -> String { - let configs = db.all_configurations().await; + #let configs = db.all_configurations().await; return format!("{configs:?}"); } From 963d3cb26085a3a3832c04004d8d2eabb41b4057 Mon Sep 17 00:00:00 2001 From: Shreelakshmi Iyengar Date: Thu, 3 Sep 2026 11:32:22 +0100 Subject: [PATCH 4/6] add force clear and exisiting configs check to transaction --- src/db_service.rs | 62 ++++++++++++++++------------------------------- 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/src/db_service.rs b/src/db_service.rs index fe5bc0e..5bc3e0f 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -23,6 +23,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteRow}; use sqlx::{query_as, FromRow, QueryBuilder, Row, Sqlite, SqlitePool}; use tracing::{info, instrument, trace}; +use crate::db_service::error::InsertConfigurationsError; use crate::paths::{ DetectorField, DetectorTemplate, DirectoryField, DirectoryTemplate, InvalidPathTemplate, PathSpec, ScanField, ScanTemplate, @@ -221,7 +222,7 @@ impl InstrumentConfigurationUpdate { } } -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] struct DbInstrumentConfig { #[allow(unused)] // unused but allows use of 'SELECT * ...' queries id: Option, @@ -327,10 +328,19 @@ impl SqliteScanPathService { pub async fn insert_configurations( &self, configs: &[InstrumentConfiguration], - ) -> Result<(), sqlx::Error> { + force_clear: bool, + ) -> Result<(), InsertConfigurationsError> { let mut tx = self.pool.begin().await?; - sqlx::query!("DelETE FROM instrument").execute(&mut tx).await?; + if force_clear{ sqlx::query!("DELETE FROM instrument").execute(&mut tx).await?; } //User has chosen to overwrite existing data so delete the table before inserting new rows + else if !configs.is_empty() { // Not forcing clear and configs is not empty, check if table is empty + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instrument") + .fetch_one(&mut tx) + .await?; + if count.0 > 0 { //Table not empty, do not clear + return Err(InsertConfigurationsError::NotEmpty); + } + } for config in configs { sqlx::query!( @@ -431,46 +441,16 @@ use crate::db_service::InstrumentConfiguration; Self::MissingField(value.into()) } } - - #[derive(Debug, Serialize, Deserialize)] // Adding DTO (Data Transfer Object) for serialisation and deserialisation of instrument configuration data - pub struct InstrumentConfigurationData { - pub name: String, - pub scan_number: u32, - pub directory: String, - pub scan: String, - pub detector: String, - pub tracker_file_extension: Option, - } - - - impl From for Instrument ConfigurationData { - fn from(config: InstrumentConfiguration) -> Self { - Self { - name: config.name, - scan_number: config.scan_number, - directory: config.directory.to_string(), - scan: config.scan.to_string(), - detector: config.detector.to_string(), - tracker_file_extension: config.tracker_file_extension, - } - } - } - impl From for Instrument Configuration { - fn from(data: InstrumentConfigurationData) -> Self { - Self { - name:data.name, - scan_number:data.scan_number, - directory:data.directory.to_string(), - scan:data.scan.to_string(), - detector:data.detector.to_string(), - tracker_file_extension:data.tracker_file_extension, - } - } + + #[derive(Debug, Display, Error, From)] + pub enum InsertConfigurationsError { + #[display("Instrument table not empty and force_clear not set to true")] + NotEmpty, + #[from] + #[display("Error inserting configurations: {_0}")] + Db(sqlx::Error), } - - - } #[cfg(test)] From 6f2341aa6c32228eb280afc426ca7d77aaca598f Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 3 Sep 2026 13:59:07 +0100 Subject: [PATCH 5/6] patch up compiler errors --- ...e10e8936995349517ac2f31f2e3fe215943b5.json | 12 +++++ ...0949236027cd2523b54c47a23382312cab6d8.json | 12 +++++ src/db_service.rs | 44 ++++++++----------- src/graphql/mod.rs | 2 +- 4 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 .sqlx/query-22237747eeb6181b9e7818733abe10e8936995349517ac2f31f2e3fe215943b5.json create mode 100644 .sqlx/query-6a62dc83c7ad9074b5ebf42ff730949236027cd2523b54c47a23382312cab6d8.json diff --git a/.sqlx/query-22237747eeb6181b9e7818733abe10e8936995349517ac2f31f2e3fe215943b5.json b/.sqlx/query-22237747eeb6181b9e7818733abe10e8936995349517ac2f31f2e3fe215943b5.json new file mode 100644 index 0000000..00f9e4d --- /dev/null +++ b/.sqlx/query-22237747eeb6181b9e7818733abe10e8936995349517ac2f31f2e3fe215943b5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO instrument (name, scan_number, directory, scan, detector, tracker_file_extension) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "22237747eeb6181b9e7818733abe10e8936995349517ac2f31f2e3fe215943b5" +} diff --git a/.sqlx/query-6a62dc83c7ad9074b5ebf42ff730949236027cd2523b54c47a23382312cab6d8.json b/.sqlx/query-6a62dc83c7ad9074b5ebf42ff730949236027cd2523b54c47a23382312cab6d8.json new file mode 100644 index 0000000..c938239 --- /dev/null +++ b/.sqlx/query-6a62dc83c7ad9074b5ebf42ff730949236027cd2523b54c47a23382312cab6d8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM instrument", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "6a62dc83c7ad9074b5ebf42ff730949236027cd2523b54c47a23382312cab6d8" +} diff --git a/src/db_service.rs b/src/db_service.rs index 5bc3e0f..522ca29 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -12,18 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -use serde::{Deserialize, Serialize}; use std::fmt; use std::marker::PhantomData; use std::path::Path; -pub use error::ConfigurationError; use error::NewConfigurationError; +pub use error::{ConfigurationError, InsertConfigurationsError}; +use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqliteConnectOptions, SqliteRow}; use sqlx::{query_as, FromRow, QueryBuilder, Row, Sqlite, SqlitePool}; use tracing::{info, instrument, trace}; -use crate::db_service::error::InsertConfigurationsError; use crate::paths::{ DetectorField, DetectorTemplate, DirectoryField, DirectoryTemplate, InvalidPathTemplate, PathSpec, ScanField, ScanTemplate, @@ -332,12 +331,18 @@ impl SqliteScanPathService { ) -> Result<(), InsertConfigurationsError> { let mut tx = self.pool.begin().await?; - if force_clear{ sqlx::query!("DELETE FROM instrument").execute(&mut tx).await?; } //User has chosen to overwrite existing data so delete the table before inserting new rows - else if !configs.is_empty() { // Not forcing clear and configs is not empty, check if table is empty - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instrument") - .fetch_one(&mut tx) + if force_clear { + //User has chosen to overwrite existing data so delete the table before inserting new rows + sqlx::query!("DELETE FROM instrument") + .execute(&mut *tx) .await?; - if count.0 > 0 { //Table not empty, do not clear + } else if !configs.is_empty() { + // Not forcing clear, check if table is empty + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instrument") + .fetch_one(&mut *tx) + .await?; + if count > 0 { + //Table not empty, do not clear return Err(InsertConfigurationsError::NotEmpty); } } @@ -347,12 +352,12 @@ impl SqliteScanPathService { "INSERT INTO instrument (name, scan_number, directory, scan, detector, tracker_file_extension) VALUES (?, ?, ?, ?, ?, ?)", config.name, config.scan_number, - config.directory, - config.scan, - config.detector, + config.directory.0, + config.scan.0, + config.detector.0, config.tracker_file_extension ) - .execute(&mut tx) + .execute(&mut *tx) .await?; } @@ -360,16 +365,6 @@ impl SqliteScanPathService { Ok(()) } - pub async fn next_scan_configuration( - &self, - instrument: &str, - current_high: Option, - ) -> Result { - let exp = current_high.unwrap_or(0); - query_as!( - DbInstrumentConfig, - } - pub async fn next_scan_configuration( &self, instrument: &str, @@ -417,8 +412,6 @@ impl fmt::Debug for SqliteScanPathService { mod error { use derive_more::{Display, Error, From}; -use crate::db_service::InstrumentConfiguration; - #[derive(Debug, Display, Error, From)] pub enum ConfigurationError { #[display("No configuration available for instrument {_0:?}")] @@ -441,7 +434,7 @@ use crate::db_service::InstrumentConfiguration; Self::MissingField(value.into()) } } - + #[derive(Debug, Display, Error, From)] pub enum InsertConfigurationsError { #[display("Instrument table not empty and force_clear not set to true")] @@ -450,7 +443,6 @@ use crate::db_service::InstrumentConfiguration; #[display("Error inserting configurations: {_0}")] Db(sqlx::Error), } - } #[cfg(test)] diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 2815c33..966df69 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -107,7 +107,7 @@ pub async fn serve_graphql(opts: ServeOptions) { } async fn export_handler(State(db): State) -> String { - #let configs = db.all_configurations().await; + let configs = db.all_configurations().await; return format!("{configs:?}"); } From ed4f11aa7d6b4a97350a47b3fdb4d4472e3772ec Mon Sep 17 00:00:00 2001 From: Shreelakshmi Iyengar Date: Thu, 3 Sep 2026 16:01:26 +0100 Subject: [PATCH 6/6] add restore handler --- src/graphql/mod.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 2815c33..7e85eac 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -36,6 +36,8 @@ use axum_extra::headers::Authorization; use axum_extra::TypedHeader; use chrono::{Datelike, Local}; use derive_more::{Display, Error}; +use serde::Deserialize; +use serde::de::value::MapDeserializer; use tokio::net::TcpListener; use tokio::select; use tokio::signal::unix::{signal, SignalKind}; @@ -43,8 +45,7 @@ use tracing::{debug, info, instrument, trace, warn}; use crate::build_info::ServerStatus; use crate::cli::ServeOptions; -use crate::db_service::{ - InstrumentConfiguration, InstrumentConfigurationUpdate, SqliteScanPathService, +use crate::db_service::{InsertConfigurationsError,InstrumentConfiguration, InstrumentConfigurationUpdate, SqliteScanPathService, }; use crate::numtracker::NumTracker; use crate::paths::{ @@ -77,7 +78,7 @@ pub async fn serve_graphql(opts: ServeOptions) { // making graphql queries .route("/status", get(server_status)) .route("/admin/export", get(export_handler)) - // .route("/admin/restore", post(restore_handler)) + .route("/admin/restore", post(restore_handler)) .route("/graphql", post(graphql_handler)) // make it obvious that /graphql isn't expected to work when visiting from a browser .route( @@ -106,9 +107,20 @@ pub async fn serve_graphql(opts: ServeOptions) { .expect("Can't serve graphql endpoint"); } -async fn export_handler(State(db): State) -> String { - #let configs = db.all_configurations().await; - return format!("{configs:?}"); +async fn export_handler(State(db): State,) -> Result>, (StatusCode)> { + let configs = db.all_configurations().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(configs)) +} + +async fn restore_handler( + State(db): State, + Query(params): Query, + Json(configs): Json>, +) -> Result { + db.insert_configurations(&configs, params.force_clear) + .await + .map_err(|e|match e{InsertConfigurationsError::NotEmpty => StatusCode::CONFLICT, InstrumentConfigurationError::Db(_) =>StatusCode::INTERNAL_SERVER_ERROR})?; + Ok("Configurations restored".into()) } async fn create_signal_handler() { @@ -144,6 +156,12 @@ async fn graphql_handler( .into() } +#[derive(Debug, Deserialize)] +struct ImportParams{ + #[serde(default)] + force_clear: bool, +} + /// Read-only API for GraphQL struct Query;