use geth_types::{DbId, ResourceId}; use rusqlite::{Connection, OpenFlags}; use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DbResource { pub id: DbId, pub resource: ResourceId, pub name: String, pub path: String, pub path_exists: bool, pub size_bytes: Option, pub schema_metadata: String, pub sync_status: String, } #[derive(Debug, thiserror::Error)] pub enum DbError { #[error("invalid db resource name: {0}")] InvalidName(String), #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), } pub fn validate_db_name(name: &str) -> Result<(), DbError> { if name.is_empty() || !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(DbError::InvalidName(name.to_owned())); } Ok(()) } pub fn schema_metadata(path: &Path) -> Result { let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; let mut stmt = conn.prepare( r#"SELECT type, name, COALESCE(sql, '') FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name"#, )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, )) })?; let mut canonical = String::new(); let mut table_count = 0_usize; let mut index_count = 0_usize; let mut view_count = 0_usize; let mut trigger_count = 0_usize; for row in rows { let (kind, name, sql) = row?; match kind.as_str() { "table" => table_count += 1, "index" => index_count += 1, "view" => view_count += 1, "trigger" => trigger_count += 1, _ => {} } canonical.push_str(&kind); canonical.push('\0'); canonical.push_str(&name); canonical.push('\0'); canonical.push_str(&sql); canonical.push('\n'); } Ok(format!( "tables={table_count} indexes={index_count} views={view_count} triggers={trigger_count} schema_hash={}", blake3::hash(canonical.as_bytes()) )) } #[must_use] pub fn crsqlite_sync_roadmap() -> &'static str { "future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes" } #[cfg(test)] mod tests { use super::*; #[test] fn db_name_validation_rejects_paths_and_empty_names() { assert!(validate_db_name("notes").is_ok()); assert!(validate_db_name("notes.v1").is_ok()); assert!(validate_db_name("").is_err()); assert!(validate_db_name("../notes").is_err()); assert!(validate_db_name("notes/main").is_err()); assert!(validate_db_name("notes main").is_err()); } #[test] fn schema_metadata_reports_counts_and_stable_hash() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("notes.sqlite"); let conn = Connection::open(&path).expect("open sqlite"); conn.execute( "CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT NOT NULL)", [], ) .expect("create table"); conn.execute("CREATE INDEX notes_body ON notes(body)", []) .expect("create index"); drop(conn); let first = schema_metadata(&path).expect("schema metadata"); let second = schema_metadata(&path).expect("schema metadata again"); assert_eq!(first, second); assert!(first.contains("tables=1")); assert!(first.contains("indexes=1")); assert!(first.contains("schema_hash=")); } }