geth/crates/geth-db/src/lib.rs

228 lines
7 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use geth_types::{DbId, ResourceId};
use rusqlite::{Connection, OpenFlags};
2026-05-15 15:08:20 +02:00
use serde::{Deserialize, Serialize};
use std::path::Path;
2026-05-15 15:08:20 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbResource {
pub id: DbId,
pub resource: ResourceId,
2026-05-16 21:13:33 +02:00
pub name: String,
2026-05-15 15:08:20 +02:00
pub path: String,
2026-05-16 21:13:33 +02:00
pub path_exists: bool,
pub size_bytes: Option<u64>,
pub schema_metadata: String,
2026-05-17 20:01:36 +02:00
pub crsqlite_changes: CrSqliteChangeMetadata,
2026-05-15 15:08:20 +02:00
pub sync_status: String,
}
2026-05-17 20:01:36 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrSqliteChangeMetadata {
pub available: bool,
pub change_count: Option<u64>,
pub max_db_version: Option<i64>,
pub columns: Vec<String>,
pub error: Option<String>,
}
impl CrSqliteChangeMetadata {
#[must_use]
pub fn unavailable() -> Self {
Self {
available: false,
change_count: None,
max_db_version: None,
columns: Vec::new(),
error: None,
}
}
#[must_use]
pub fn error(error: impl Into<String>) -> Self {
Self {
available: false,
change_count: None,
max_db_version: None,
columns: Vec::new(),
error: Some(error.into()),
}
}
}
2026-05-16 21:13:33 +02:00
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("invalid db resource name: {0}")]
InvalidName(String),
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
2026-05-16 21:13:33 +02:00
}
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<String, DbError> {
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())
))
}
2026-05-17 20:01:36 +02:00
pub fn crsqlite_change_metadata(path: &Path) -> Result<CrSqliteChangeMetadata, DbError> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let available: bool = conn.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM sqlite_master
WHERE name = 'crsql_changes' AND type IN ('table', 'view')
)"#,
[],
|row| row.get(0),
)?;
if !available {
return Ok(CrSqliteChangeMetadata::unavailable());
}
let mut columns_stmt = conn.prepare("PRAGMA table_info('crsql_changes')")?;
let columns = columns_stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
let change_count: u64 =
conn.query_row("SELECT COUNT(*) FROM crsql_changes", [], |row| row.get(0))?;
let max_db_version = if columns.iter().any(|column| column == "db_version") {
conn.query_row("SELECT MAX(db_version) FROM crsql_changes", [], |row| {
row.get(0)
})?
} else {
None
};
Ok(CrSqliteChangeMetadata {
available: true,
change_count: Some(change_count),
max_db_version,
columns,
error: None,
})
}
2026-05-15 15:08:20 +02:00
#[must_use]
pub fn crsqlite_sync_roadmap() -> &'static str {
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
}
2026-05-16 21:13:33 +02:00
#[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="));
}
2026-05-17 20:01:36 +02:00
#[test]
fn crsqlite_change_metadata_reports_mock_change_table() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("notes.sqlite");
let conn = Connection::open(&path).expect("open sqlite");
conn.execute(
r#"CREATE TABLE crsql_changes(
table_name TEXT NOT NULL,
pk TEXT NOT NULL,
cid TEXT NOT NULL,
val BLOB,
col_version INTEGER NOT NULL,
db_version INTEGER NOT NULL,
site_id BLOB,
cl INTEGER,
seq INTEGER
)"#,
[],
)
.expect("create crsql_changes table");
conn.execute(
"INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version) VALUES ('notes', '1', 'body', 'hello', 1, 7)",
[],
)
.expect("insert change");
drop(conn);
let metadata = crsqlite_change_metadata(&path).expect("change metadata");
assert!(metadata.available);
assert_eq!(metadata.change_count, Some(1));
assert_eq!(metadata.max_db_version, Some(7));
assert!(metadata.columns.contains(&"db_version".to_owned()));
}
2026-05-16 21:13:33 +02:00
}