diff --git a/AGENTS.md b/AGENTS.md index fc3d88b..db02a33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,8 +125,8 @@ Roadmap items should be actionable and checkable: replication and command-level prefix-capability enforcement are still roadmap work. The auth evaluator already understands `kv.write_prefix:` grants for `kv.write_key:` requests. -- Document resources can be registered locally with empty JSON state and - local-only status. Automerge editing/state and sync are still roadmap work. +- Document resources can be registered locally and updated with validated local + JSON state. Automerge editing/state and sync are still roadmap work. - Pubsub supports local daemon-lifetime publish/subscribe snapshots through a bounded in-memory ring buffer. Iroh-gossip replication, private topics, and pubsub capability enforcement are still roadmap work. diff --git a/Cargo.lock b/Cargo.lock index ad899a7..1cb2265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,7 @@ version = "0.1.0" dependencies = [ "geth-types", "serde", + "serde_json", "thiserror 2.0.18", ] diff --git a/README.md b/README.md index 0a7c9ae..4732d99 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ The bootstrap implementation provides: - local DB resource registration: `geth db add ` and `geth db status ` - local SQLite-backed KV commands: `geth kv create/set/get` -- local document resource registration: `geth document create/status` +- local JSON document commands: `geth document create/status/set/get` - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub` - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index aaefd2d..f13b8d1 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -256,6 +256,8 @@ pub enum DbCommand { pub enum DocumentCommand { Create { name: String }, Status { name: String }, + Set { name: String, state_json: String }, + Get { name: String }, } #[derive(Debug, Subcommand)] @@ -471,6 +473,10 @@ fn request_for_command(command: Command) -> Result { Command::Document { command } => match command { DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name }, DocumentCommand::Status { name } => ControlRequest::DocumentStatus { name }, + DocumentCommand::Set { name, state_json } => { + ControlRequest::DocumentSet { name, state_json } + } + DocumentCommand::Get { name } => ControlRequest::DocumentGet { name }, }, Command::Ssh { command } => match command { SshCommand::Proxy { node } => ControlRequest::ModuleStub { @@ -904,6 +910,14 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("sync_status: {}", document.sync_status); println!("state_bytes: {}", document.state_bytes); } + ControlResponse::DocumentSet { state } => { + println!("updated document: {}", state.document.name); + println!("state_bytes: {}", state.document.state_bytes); + println!("updated_at_ms: {}", state.updated_at.0); + } + ControlResponse::DocumentGet { state } => { + println!("{}", state.state_json); + } ControlResponse::PubsubPublished { message } => { println!("published: {}", message.topic); println!("published_at_ms: {}", message.published_at.0); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 9ddd88e..9dfe8d6 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,6 +1,6 @@ use geth_auth::{AuthExplanation, AuthOp}; use geth_db::DbResource; -use geth_document::DocumentResource; +use geth_document::{DocumentResource, DocumentState}; use geth_keychain::KeychainOp; use geth_kv::{KvEntry, KvResource}; use geth_pubsub::PubsubMessage; @@ -138,6 +138,13 @@ pub enum ControlRequest { DocumentStatus { name: String, }, + DocumentSet { + name: String, + state_json: String, + }, + DocumentGet { + name: String, + }, PubsubPub { topic: String, message: String, @@ -263,6 +270,12 @@ pub enum ControlResponse { DocumentStatus { document: DocumentResource, }, + DocumentSet { + state: DocumentState, + }, + DocumentGet { + state: DocumentState, + }, PubsubPublished { message: PubsubMessage, }, @@ -384,5 +397,14 @@ mod tests { decode_response(&encode_response(&response).expect("encode")).expect("decode"), response ); + + let request = ControlRequest::DocumentSet { + name: "notes".to_owned(), + state_json: r#"{"title":"notes"}"#.to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); } } diff --git a/crates/geth-document/Cargo.toml b/crates/geth-document/Cargo.toml index 27f4b00..3ccc51c 100644 --- a/crates/geth-document/Cargo.toml +++ b/crates/geth-document/Cargo.toml @@ -7,5 +7,6 @@ license.workspace = true [dependencies] serde.workspace = true +serde_json.workspace = true thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-document/src/lib.rs b/crates/geth-document/src/lib.rs index 5723d61..fd771a4 100644 --- a/crates/geth-document/src/lib.rs +++ b/crates/geth-document/src/lib.rs @@ -1,4 +1,4 @@ -use geth_types::{DocumentId, ResourceId}; +use geth_types::{DocumentId, ResourceId, UnixMillis}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -10,10 +10,19 @@ pub struct DocumentResource { pub state_bytes: u64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentState { + pub document: DocumentResource, + pub state_json: String, + pub updated_at: UnixMillis, +} + #[derive(Debug, thiserror::Error)] pub enum DocumentError { #[error("invalid document name: {0}")] InvalidName(String), + #[error("invalid document JSON state: {0}")] + InvalidState(#[from] serde_json::Error), } pub fn validate_document_name(name: &str) -> Result<(), DocumentError> { @@ -27,6 +36,11 @@ pub fn validate_document_name(name: &str) -> Result<(), DocumentError> { Ok(()) } +pub fn normalize_document_state(state_json: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(state_json)?; + Ok(serde_json::to_string(&value)?) +} + #[must_use] pub fn automerge_roadmap() -> &'static str { "future documents use Automerge sync over Iroh with resource-local authorization" @@ -45,4 +59,13 @@ mod tests { assert!(validate_document_name("notes/main").is_err()); assert!(validate_document_name("notes main").is_err()); } + + #[test] + fn document_state_is_validated_and_normalized_json() { + assert_eq!( + normalize_document_state(r#"{ "title": "notes", "done": false }"#).expect("normalize"), + r#"{"done":false,"title":"notes"}"# + ); + assert!(normalize_document_state("{").is_err()); + } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 158d766..2191150 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -9,7 +9,7 @@ use geth_control::{ }; use geth_crypto::AgentKey; use geth_db::DbResource; -use geth_document::DocumentResource; +use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_kv::{KvEntry, KvResource}; @@ -72,6 +72,8 @@ pub enum NodeError { InvalidDocumentName(String), #[error("document not found: {0}")] DocumentNotFound(String), + #[error("document error: {0}")] + Document(#[from] geth_document::DocumentError), #[error("resource not found: {0}")] ResourceNotFound(String), #[error("secrets error: {0}")] @@ -826,6 +828,29 @@ pub fn handle_request( document: document_resource_from_stored(&stored), }) } + ControlRequest::DocumentSet { name, state_json } => { + geth_document::validate_document_name(&name) + .map_err(|_| NodeError::InvalidDocumentName(name.clone()))?; + let mut stored = store + .get_document_resource_by_name(&name)? + .ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?; + stored.state_json = geth_document::normalize_document_state(&state_json)?; + stored.updated_at_ms = geth_store::now_ms(); + store.insert_document_resource(&stored)?; + Ok(ControlResponse::DocumentSet { + state: document_state_from_stored(&stored), + }) + } + ControlRequest::DocumentGet { name } => { + geth_document::validate_document_name(&name) + .map_err(|_| NodeError::InvalidDocumentName(name.clone()))?; + let stored = store + .get_document_resource_by_name(&name)? + .ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?; + Ok(ControlResponse::DocumentGet { + state: document_state_from_stored(&stored), + }) + } ControlRequest::PubsubPub { topic, message } => { geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_message(&message)?; @@ -938,6 +963,14 @@ fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentRes } } +fn document_state_from_stored(stored: &StoredDocumentResource) -> DocumentState { + DocumentState { + document: document_resource_from_stored(stored), + state_json: stored.state_json.clone(), + updated_at: UnixMillis(stored.updated_at_ms), + } +} + fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> { if store .list_resources()? diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 7cc46e3..0479add 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -589,6 +589,50 @@ fn document_create_and_status_use_local_store() { other => panic!("unexpected response: {other:?}"), } + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentSet { + name: "notes".to_owned(), + state_json: r#"{ "title": "notes", "items": [1, 2] }"#.to_owned(), + }, + ) + .expect("document set"); + match response { + geth_control::ControlResponse::DocumentSet { state } => { + assert_eq!(state.document.name, "notes"); + assert_eq!(state.state_json, r#"{"items":[1,2],"title":"notes"}"#); + assert!(state.document.state_bytes > 2); + } + other => panic!("unexpected response: {other:?}"), + } + + let reopened = geth_node::open_node(&paths).expect("reopen node"); + let response = geth_node::handle_request( + &reopened, + geth_control::ControlRequest::DocumentGet { + name: "notes".to_owned(), + }, + ) + .expect("document get"); + match response { + geth_control::ControlResponse::DocumentGet { state } => { + assert_eq!(state.state_json, r#"{"items":[1,2],"title":"notes"}"#); + assert!(state.updated_at.0 > 0); + } + other => panic!("unexpected response: {other:?}"), + } + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentSet { + name: "notes".to_owned(), + state_json: "{".to_owned(), + }, + ) + .is_err() + ); + assert!( geth_node::handle_request( &node, diff --git a/docs/architecture.md b/docs/architecture.md index f60019d..acc5f9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,9 +101,10 @@ loading, change extraction, and DB sync are future work. through `kv create/set/get`. Iroh Documents namespaces, prefix authorization enforcement, and replication are future work. -`geth-document` currently registers local document resources with an empty JSON -state placeholder and local-only sync status. Automerge state, editing, and sync -are future work. +`geth-document` currently registers local document resources and stores +validated JSON state in the local SQLite metadata store through +`document create/status/set/get`. This is a bootstrap editing surface, not yet +Automerge CRDT state. Automerge state encoding and sync are future work. `geth-pubsub` currently supports local publish/subscribe snapshots through the daemon control protocol. Messages live in a bounded in-memory ring buffer and diff --git a/docs/roadmap.md b/docs/roadmap.md index 44f7f3e..0566657 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -318,8 +318,12 @@ Automerge documents. Acceptance criteria: - `[x]` `geth document create/status` works for local documents. - `[x]` Document metadata and empty JSON state are stored durably. + - `[x]` `geth document set/get` stores and returns validated local JSON + state. + - `[x]` Tests cover create, update, save, and reload of local JSON document + state. - `[ ]` Automerge document state is stored durably. - - `[ ]` Tests cover create, update, save, and reload. + - `[ ]` Tests cover Automerge create, update, save, and reload. - `[ ]` Automerge sync over Iroh. Acceptance criteria: