From a44eef31262bc0a76b997061819a2b147ea4e7c6 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sun, 17 May 2026 20:32:36 +0200 Subject: [PATCH] Expose db change batches over control --- AGENTS.md | 6 ++--- README.md | 3 ++- crates/geth-cli/src/lib.rs | 43 ++++++++++++++++++++++++++++++++-- crates/geth-control/src/lib.rs | 23 ++++++++++++++++-- crates/geth-node/src/lib.rs | 19 +++++++++++++++ crates/geth/tests/bootstrap.rs | 38 ++++++++++++++++++++++++++++++ docs/architecture.md | 8 +++---- docs/roadmap.md | 5 ++-- 8 files changed, 130 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b62330a..212268e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,9 +120,9 @@ Roadmap items should be actionable and checkable: `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 and `crsql_changes` metadata when - present. The DB crate can extract typed read-only `crsql_changes` batches for - future sync messages. cr-sqlite loading, applying remote changes, and sync are - still roadmap work. + present. The DB crate and daemon can extract typed read-only `crsql_changes` + batches through `geth db changes` for future sync messages. cr-sqlite loading, + applying remote changes, 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 17a69e3..9cd1f70 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ The bootstrap implementation provides: `cleanup`, `list` - local DB resource registration: `geth db add ` and `geth db status ` with schema and `crsql_changes` metadata; the DB - crate can extract typed local `crsql_changes` batches for future sync + crate and daemon can extract typed local `crsql_changes` batches through + `geth db changes ` for future sync - 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 78e0afb..59f2f4f 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -248,8 +248,20 @@ pub enum PipeCommand { #[derive(Debug, Subcommand)] pub enum DbCommand { - Add { name: String, path: PathBuf }, - Status { name: String }, + Add { + name: String, + path: PathBuf, + }, + Status { + name: String, + }, + Changes { + name: String, + #[arg(long)] + after_db_version: Option, + #[arg(long, default_value_t = 100)] + limit: u32, + }, } #[derive(Debug, Subcommand)] @@ -469,6 +481,15 @@ fn request_for_command(command: Command) -> Result { Command::Db { command } => match command { DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, DbCommand::Status { name } => ControlRequest::DbStatus { name }, + DbCommand::Changes { + name, + after_db_version, + limit, + } => ControlRequest::DbChanges { + name, + after_db_version, + limit, + }, }, Command::Document { command } => match command { DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name }, @@ -893,6 +914,24 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("sync_status: {}", db.sync_status); } + ControlResponse::DbChanges { db, batch } => { + println!("db: {}", db.name); + println!("changes: {}", batch.changes.len()); + println!( + "max_db_version: {}", + batch + .max_db_version + .map(|version| version.to_string()) + .unwrap_or_else(|| "none".to_owned()) + ); + println!("schema_metadata: {}", batch.schema_metadata); + for change in batch.changes { + println!( + "{}\t{}\t{}", + change.db_version, change.table_name, change.column_id + ); + } + } ControlResponse::KvCreated { kv } => { println!("created kv: {}", kv.name); println!("id: {}", kv.id); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index f60d094..9aefff7 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,5 +1,5 @@ use geth_auth::{AuthExplanation, AuthOp}; -use geth_db::DbResource; +use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::KeychainOp; use geth_kv::{KvEntry, KvResource}; @@ -121,6 +121,11 @@ pub enum ControlRequest { DbStatus { name: String, }, + DbChanges { + name: String, + after_db_version: Option, + limit: u32, + }, KvCreate { name: String, }, @@ -165,7 +170,7 @@ pub enum ControlRequest { }, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum ControlResponse { Status(StatusResponse), @@ -262,6 +267,10 @@ pub enum ControlResponse { DbStatus { db: DbResource, }, + DbChanges { + db: DbResource, + batch: CrSqliteChangeBatch, + }, KvCreated { kv: KvResource, }, @@ -427,5 +436,15 @@ mod tests { decode_request(&encode_request(&request).expect("encode")).expect("decode"), request ); + + let request = ControlRequest::DbChanges { + name: "notes".to_owned(), + after_db_version: Some(7), + limit: 10, + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 8fbcae7..aee83e6 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -758,6 +758,25 @@ pub fn handle_request( db: db_resource_from_stored(&stored)?, }) } + ControlRequest::DbChanges { + name, + after_db_version, + limit, + } => { + geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; + let stored = store + .get_db_resource_by_name(&name)? + .ok_or_else(|| NodeError::DbNotFound(name.clone()))?; + let batch = geth_db::extract_crsqlite_changes( + Path::new(&stored.path), + after_db_version, + limit, + )?; + Ok(ControlResponse::DbChanges { + db: db_resource_from_stored(&stored)?, + batch, + }) + } ControlRequest::KvCreate { name } => { geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?; let resource_id = format!("resource:kv:{name}"); diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index e85d5c9..65cdeb6 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -463,6 +463,44 @@ fn db_add_and_status_register_local_db_metadata() { other => panic!("unexpected response: {other:?}"), } + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DbChanges { + name: "notes".to_owned(), + after_db_version: None, + limit: 10, + }, + ) + .expect("db changes"); + match response { + geth_control::ControlResponse::DbChanges { db, batch } => { + assert_eq!(db.name, "notes"); + assert_eq!(batch.changes.len(), 1); + assert_eq!(batch.max_db_version, Some(3)); + assert_eq!(batch.changes[0].table_name, "notes"); + assert_eq!(batch.changes[0].column_id, "body"); + assert!(batch.schema_metadata.contains("schema_hash=")); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DbChanges { + name: "notes".to_owned(), + after_db_version: Some(3), + limit: 10, + }, + ) + .expect("db changes after latest"); + match response { + geth_control::ControlResponse::DbChanges { batch, .. } => { + assert!(batch.changes.is_empty()); + assert_eq!(batch.max_db_version, None); + } + other => panic!("unexpected response: {other:?}"), + } + assert!( geth_node::handle_request( &node, diff --git a/docs/architecture.md b/docs/architecture.md index dcc91a3..a2cb1c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,10 +96,10 @@ 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. It also inspects `crsql_changes` metadata when that table or view exists, reporting -change count, columns, and max `db_version`. The crate can extract read-only -typed change batches from `crsql_changes` with schema metadata for future sync -messages. Loading cr-sqlite, applying remote changes, and DB sync are future -work. +change count, columns, and max `db_version`. The crate and local daemon can +extract read-only typed change batches from `crsql_changes` with schema metadata +through `db changes`. Loading cr-sqlite, applying remote 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 94af58d..8966d40 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -306,7 +306,7 @@ Automerge documents. - `geth db status ` reports local path, schema metadata, and sync state. - Nonexistent paths and invalid names produce clear errors. -- `[ ]` cr-sqlite change extraction. +- `[x]` cr-sqlite change extraction. Acceptance criteria: - `[x]` DB status detects whether `crsql_changes` exists. - `[x]` DB status reports `crsql_changes` row count, columns, and max @@ -314,8 +314,7 @@ Automerge documents. - `[x]` Tests use a temp SQLite DB and deterministic fixture changes. - `[x]` The module can extract typed change batches from `crsql_changes`. - `[x]` Schema hash/version metadata is included in extracted change batches. - - `[ ]` Extracted batches are exposed through a daemon control command or sync - protocol. + - `[x]` Extracted batches are exposed through `geth db changes`. - `[ ]` DB sync over Iroh. Acceptance criteria: