Add local document JSON state commands

This commit is contained in:
Eric Wendland 2026-05-17 19:59:03 +02:00
commit e039a4f81f
11 changed files with 153 additions and 10 deletions

View file

@ -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:<prefix>`
grants for `kv.write_key:<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.

1
Cargo.lock generated
View file

@ -1172,6 +1172,7 @@ version = "0.1.0"
dependencies = [
"geth-types",
"serde",
"serde_json",
"thiserror 2.0.18",
]

View file

@ -90,7 +90,7 @@ The bootstrap implementation provides:
- local DB resource registration: `geth db add <name> <path>` and
`geth db status <name>`
- 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 <path> --principal <name>`

View file

@ -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<ControlRequest> {
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);

View file

@ -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
);
}
}

View file

@ -7,5 +7,6 @@ license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -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<String, DocumentError> {
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());
}
}

View file

@ -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()?

View file

@ -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,

View file

@ -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

View file

@ -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: