Add SQLite schema metadata for DB resources

This commit is contained in:
Eric Wendland 2026-05-16 21:54:49 +02:00
commit 527090fd4c
8 changed files with 119 additions and 14 deletions

View file

@ -6,6 +6,11 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
rusqlite.workspace = true
serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }
[dev-dependencies]
tempfile.workspace = true

View file

@ -1,5 +1,7 @@
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 {
@ -17,6 +19,8 @@ pub struct DbResource {
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> {
@ -30,6 +34,50 @@ pub fn validate_db_name(name: &str) -> Result<(), DbError> {
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())
))
}
#[must_use]
pub fn crsqlite_sync_roadmap() -> &'static str {
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
@ -48,4 +96,27 @@ mod tests {
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="));
}
}

View file

@ -40,6 +40,8 @@ pub enum NodeError {
Store(#[from] geth_store::StoreError),
#[error("cas error: {0}")]
Cas(#[from] geth_cas::CasError),
#[error("db error: {0}")]
Db(#[from] geth_db::DbError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("json error: {0}")]
@ -570,6 +572,7 @@ pub fn handle_request(
return Err(NodeError::InvalidDbPath(path.display().to_string()));
}
let path = std::fs::canonicalize(path)?;
let schema_metadata = geth_db::schema_metadata(&path)?;
let resource_id = format!("resource:db:{name}");
let db_id = format!("db:{name}");
let resource = StoredResource {
@ -587,7 +590,7 @@ pub fn handle_request(
};
store.insert_db_resource(&stored)?;
Ok(ControlResponse::DbAdded {
db: db_resource_from_stored(&stored)?,
db: db_resource_from_stored_with_schema(&stored, schema_metadata),
})
}
ControlRequest::DbStatus { name } => {
@ -669,16 +672,30 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
fn db_resource_from_stored(stored: &StoredDbResource) -> Result<DbResource, NodeError> {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
Ok(DbResource {
let schema_metadata = if metadata.as_ref().is_some_and(std::fs::Metadata::is_file) {
geth_db::schema_metadata(path).unwrap_or_else(|error| format!("unavailable:{error}"))
} else {
"unavailable:path-missing".to_owned()
};
Ok(db_resource_from_stored_with_schema(stored, schema_metadata))
}
fn db_resource_from_stored_with_schema(
stored: &StoredDbResource,
schema_metadata: String,
) -> DbResource {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
DbResource {
id: stored.db_id.clone().into(),
resource: stored.resource_id.clone().into(),
name: stored.name.clone(),
path: stored.path.clone(),
path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file),
size_bytes: metadata.map(|metadata| metadata.len()),
schema_metadata: "not-inspected-until-crsqlite-integration".to_owned(),
schema_metadata,
sync_status: "local-only".to_owned(),
})
}
}
fn kv_resource_from_stored(stored: &StoredKvStore) -> KvResource {

View file

@ -21,4 +21,5 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-node = { path = "../geth-node" }
geth-store = { path = "../geth-store" }
rusqlite.workspace = true
tempfile.workspace = true

View file

@ -393,7 +393,13 @@ fn db_add_and_status_register_local_db_metadata() {
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let db_path = home.path().join("notes.sqlite");
std::fs::write(&db_path, b"sqlite placeholder").expect("write db");
let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
conn.execute(
"CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT NOT NULL)",
[],
)
.expect("create notes table");
drop(conn);
let response = geth_node::handle_request(
&node,
@ -408,7 +414,9 @@ fn db_add_and_status_register_local_db_metadata() {
assert_eq!(db.name, "notes");
assert_eq!(db.sync_status, "local-only");
assert!(db.path_exists);
assert_eq!(db.size_bytes, Some(18));
assert!(db.size_bytes.unwrap_or_default() > 0);
assert!(db.schema_metadata.contains("tables=1"));
assert!(db.schema_metadata.contains("schema_hash="));
}
other => panic!("unexpected response: {other:?}"),
}
@ -424,10 +432,8 @@ fn db_add_and_status_register_local_db_metadata() {
geth_control::ControlResponse::DbStatus { db } => {
assert_eq!(db.name, "notes");
assert!(db.path.ends_with("notes.sqlite"));
assert_eq!(
db.schema_metadata,
"not-inspected-until-crsqlite-integration"
);
assert!(db.schema_metadata.contains("tables=1"));
assert!(db.schema_metadata.contains("schema_hash="));
}
other => panic!("unexpected response: {other:?}"),
}