diff --git a/AGENTS.md b/AGENTS.md index db02a33..c3fff78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,8 +119,9 @@ Roadmap items should be actionable and checkable: - 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 plus a - read-only SQLite schema summary/hash. cr-sqlite loading, change extraction, - and sync are still roadmap work. + read-only SQLite schema summary/hash and `crsql_changes` metadata when + present. cr-sqlite loading, typed change extraction, and sync are still + roadmap work. - KV stores support local SQLite-backed create/set/get. Iroh Documents replication and command-level prefix-capability enforcement are still roadmap work. The auth evaluator already understands `kv.write_prefix:` diff --git a/README.md b/README.md index 4732d99..50df0d2 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ The bootstrap implementation provides: - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`, `cleanup`, `list` - local DB resource registration: `geth db add ` and - `geth db status ` + `geth db status ` with schema and `crsql_changes` metadata - local SQLite-backed KV commands: `geth kv create/set/get` - local JSON document commands: `geth document create/status/set/get` - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index f13b8d1..7c7951d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -878,6 +878,19 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { .unwrap_or_else(|| "unknown".to_owned()) ); println!("schema_metadata: {}", db.schema_metadata); + println!( + "crsqlite_changes_available: {}", + db.crsqlite_changes.available + ); + if let Some(count) = db.crsqlite_changes.change_count { + println!("crsqlite_change_count: {count}"); + } + if let Some(version) = db.crsqlite_changes.max_db_version { + println!("crsqlite_max_db_version: {version}"); + } + if let Some(error) = db.crsqlite_changes.error { + println!("crsqlite_changes_error: {error}"); + } println!("sync_status: {}", db.sync_status); } ControlResponse::KvCreated { kv } => { diff --git a/crates/geth-db/src/lib.rs b/crates/geth-db/src/lib.rs index 1efe855..c3be119 100644 --- a/crates/geth-db/src/lib.rs +++ b/crates/geth-db/src/lib.rs @@ -12,9 +12,43 @@ pub struct DbResource { pub path_exists: bool, pub size_bytes: Option, pub schema_metadata: String, + pub crsqlite_changes: CrSqliteChangeMetadata, pub sync_status: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrSqliteChangeMetadata { + pub available: bool, + pub change_count: Option, + pub max_db_version: Option, + pub columns: Vec, + pub error: Option, +} + +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) -> Self { + Self { + available: false, + change_count: None, + max_db_version: None, + columns: Vec::new(), + error: Some(error.into()), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum DbError { #[error("invalid db resource name: {0}")] @@ -78,6 +112,43 @@ pub fn schema_metadata(path: &Path) -> Result { )) } +pub fn crsqlite_change_metadata(path: &Path) -> Result { + 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::, _>>()?; + 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, + }) +} + #[must_use] pub fn crsqlite_sync_roadmap() -> &'static str { "future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes" @@ -119,4 +190,39 @@ mod tests { assert!(first.contains("indexes=1")); assert!(first.contains("schema_hash=")); } + + #[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())); + } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 2191150..f4e8a11 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -716,6 +716,7 @@ pub fn handle_request( } let path = std::fs::canonicalize(path)?; let schema_metadata = geth_db::schema_metadata(&path)?; + let crsqlite_changes = geth_db::crsqlite_change_metadata(&path)?; let resource_id = format!("resource:db:{name}"); let db_id = format!("db:{name}"); let resource = StoredResource { @@ -733,7 +734,7 @@ pub fn handle_request( }; store.insert_db_resource(&stored)?; Ok(ControlResponse::DbAdded { - db: db_resource_from_stored_with_schema(&stored, schema_metadata), + db: db_resource_from_stored_with_schema(&stored, schema_metadata, crsqlite_changes), }) } ControlRequest::DbStatus { name } => { @@ -910,17 +911,32 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result Result { let path = Path::new(&stored.path); let metadata = path.metadata().ok(); - 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}")) + let (schema_metadata, crsqlite_changes) = if metadata + .as_ref() + .is_some_and(std::fs::Metadata::is_file) + { + ( + geth_db::schema_metadata(path).unwrap_or_else(|error| format!("unavailable:{error}")), + geth_db::crsqlite_change_metadata(path) + .unwrap_or_else(|error| geth_db::CrSqliteChangeMetadata::error(error.to_string())), + ) } else { - "unavailable:path-missing".to_owned() + ( + "unavailable:path-missing".to_owned(), + geth_db::CrSqliteChangeMetadata::error("path-missing"), + ) }; - Ok(db_resource_from_stored_with_schema(stored, schema_metadata)) + Ok(db_resource_from_stored_with_schema( + stored, + schema_metadata, + crsqlite_changes, + )) } fn db_resource_from_stored_with_schema( stored: &StoredDbResource, schema_metadata: String, + crsqlite_changes: geth_db::CrSqliteChangeMetadata, ) -> DbResource { let path = Path::new(&stored.path); let metadata = path.metadata().ok(); @@ -932,6 +948,7 @@ fn db_resource_from_stored_with_schema( path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file), size_bytes: metadata.map(|metadata| metadata.len()), schema_metadata, + crsqlite_changes, sync_status: "local-only".to_owned(), } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 0479add..73465a4 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -399,6 +399,23 @@ fn db_add_and_status_register_local_db_metadata() { [], ) .expect("create notes table"); + 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 + )"#, + [], + ) + .expect("create crsql_changes"); + conn.execute( + "INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version) VALUES ('notes', '1', 'body', 'hello', 1, 3)", + [], + ) + .expect("insert crsql change"); drop(conn); let response = geth_node::handle_request( @@ -415,8 +432,11 @@ fn db_add_and_status_register_local_db_metadata() { assert_eq!(db.sync_status, "local-only"); assert!(db.path_exists); assert!(db.size_bytes.unwrap_or_default() > 0); - assert!(db.schema_metadata.contains("tables=1")); + assert!(db.schema_metadata.contains("tables=2")); assert!(db.schema_metadata.contains("schema_hash=")); + assert!(db.crsqlite_changes.available); + assert_eq!(db.crsqlite_changes.change_count, Some(1)); + assert_eq!(db.crsqlite_changes.max_db_version, Some(3)); } other => panic!("unexpected response: {other:?}"), } @@ -432,8 +452,13 @@ 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!(db.schema_metadata.contains("tables=1")); + assert!(db.schema_metadata.contains("tables=2")); assert!(db.schema_metadata.contains("schema_hash=")); + assert!( + db.crsqlite_changes + .columns + .contains(&"db_version".to_owned()) + ); } other => panic!("unexpected response: {other:?}"), } diff --git a/docs/architecture.md b/docs/architecture.md index acc5f9c..b8a7840 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,8 +94,10 @@ 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 a read-only SQLite schema summary/hash. cr-sqlite -loading, change extraction, and DB sync are future work. +local-only sync status plus a read-only SQLite schema summary/hash. It also +inspects `crsql_changes` metadata when that table or view exists, reporting +change count, columns, and max `db_version`. Loading cr-sqlite, extracting +change batches, applying changes, 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 0566657..9496ab5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -304,9 +304,12 @@ Automerge documents. - `[ ]` cr-sqlite change extraction. Acceptance criteria: - - The module can read changes from `crsql_changes`. - - Schema hash/version metadata is included in sync batches. - - Tests use a temp SQLite DB and deterministic fixture changes. + - `[x]` DB status detects whether `crsql_changes` exists. + - `[x]` DB status reports `crsql_changes` row count, columns, and max + `db_version` when available. + - `[x]` Tests use a temp SQLite DB and deterministic fixture changes. + - `[ ]` The module can extract typed change batches from `crsql_changes`. + - `[ ]` Schema hash/version metadata is included in sync batches. - `[ ]` DB sync over Iroh. Acceptance criteria: