From 527090fd4c348fbd202d99b2cda0592403698ec5 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 21:54:49 +0200 Subject: [PATCH] Add SQLite schema metadata for DB resources --- AGENTS.md | 5 ++- Cargo.lock | 4 ++ crates/geth-db/Cargo.toml | 5 +++ crates/geth-db/src/lib.rs | 71 ++++++++++++++++++++++++++++++++++ crates/geth-node/src/lib.rs | 25 ++++++++++-- crates/geth/Cargo.toml | 1 + crates/geth/tests/bootstrap.rs | 18 ++++++--- docs/architecture.md | 4 +- 8 files changed, 119 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7e9ef0..df5dcd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,9 @@ Roadmap items should be actionable and checkable: `keychain status`. SSH signature capture/verification is still roadmap work. - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and `cas cleanup` evicts unpinned blobs while retaining pinned blobs. -- DB resources can be registered locally and report local-only status. cr-sqlite - loading, change extraction, and sync are still roadmap work. +- DB resources can be registered locally and report local-only status plus a + read-only SQLite schema summary/hash. cr-sqlite loading, change extraction, + and sync are still roadmap work. - KV stores support local SQLite-backed create/set/get. Iroh Documents replication and prefix-capability enforcement are still roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, diff --git a/Cargo.lock b/Cargo.lock index 6924613..d3234f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1047,6 +1047,7 @@ dependencies = [ "geth-control", "geth-node", "geth-store", + "rusqlite", "tempfile", "tokio", "tracing-subscriber", @@ -1145,8 +1146,11 @@ dependencies = [ name = "geth-db" version = "0.1.0" dependencies = [ + "blake3", "geth-types", + "rusqlite", "serde", + "tempfile", "thiserror 2.0.18", ] diff --git a/crates/geth-db/Cargo.toml b/crates/geth-db/Cargo.toml index b7a1365..2560627 100644 --- a/crates/geth-db/Cargo.toml +++ b/crates/geth-db/Cargo.toml @@ -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 diff --git a/crates/geth-db/src/lib.rs b/crates/geth-db/src/lib.rs index 16f940e..1efe855 100644 --- a/crates/geth-db/src/lib.rs +++ b/crates/geth-db/src/lib.rs @@ -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 { + 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=")); + } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 0e069dd..8064042 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -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 Result { 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 { diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index a5aa98c..1a0b8b8 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -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 diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index f5dab99..2fccd8a 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -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:?}"), } diff --git a/docs/architecture.md b/docs/architecture.md index 6b09cf4..3b22684 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,8 +94,8 @@ blobs. Iroh-blobs, providers, encrypted blobs, richer cache policies, manifests, and file sync trees are future work. `geth-db` currently registers local SQLite paths as DB resources and reports -local-only sync status plus placeholder schema metadata. cr-sqlite loading, -change extraction, and DB sync are future work. +local-only sync status plus a read-only SQLite schema summary/hash. cr-sqlite +loading, change extraction, and DB sync are future work. `geth-kv` currently provides a SQLite-backed local fallback for named KV stores through `kv create/set/get`. Iroh Documents namespaces, prefix authorization