Add local document JSON state commands
This commit is contained in:
parent
b102e07204
commit
e039a4f81f
11 changed files with 153 additions and 10 deletions
|
|
@ -125,8 +125,8 @@ Roadmap items should be actionable and checkable:
|
||||||
replication and command-level prefix-capability enforcement are still roadmap
|
replication and command-level prefix-capability enforcement are still roadmap
|
||||||
work. The auth evaluator already understands `kv.write_prefix:<prefix>`
|
work. The auth evaluator already understands `kv.write_prefix:<prefix>`
|
||||||
grants for `kv.write_key:<key>` requests.
|
grants for `kv.write_key:<key>` requests.
|
||||||
- Document resources can be registered locally with empty JSON state and
|
- Document resources can be registered locally and updated with validated local
|
||||||
local-only status. Automerge editing/state and sync are still roadmap work.
|
JSON state. Automerge editing/state and sync are still roadmap work.
|
||||||
- Pubsub supports local daemon-lifetime publish/subscribe snapshots through a
|
- Pubsub supports local daemon-lifetime publish/subscribe snapshots through a
|
||||||
bounded in-memory ring buffer. Iroh-gossip replication, private topics, and
|
bounded in-memory ring buffer. Iroh-gossip replication, private topics, and
|
||||||
pubsub capability enforcement are still roadmap work.
|
pubsub capability enforcement are still roadmap work.
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1172,6 +1172,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"geth-types",
|
"geth-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ The bootstrap implementation provides:
|
||||||
- local DB resource registration: `geth db add <name> <path>` and
|
- local DB resource registration: `geth db add <name> <path>` and
|
||||||
`geth db status <name>`
|
`geth db status <name>`
|
||||||
- local SQLite-backed KV commands: `geth kv create/set/get`
|
- 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`
|
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`
|
||||||
- SSH certificate flow metadata:
|
- SSH certificate flow metadata:
|
||||||
- `geth ssh cert request --public-key <path> --principal <name>`
|
- `geth ssh cert request --public-key <path> --principal <name>`
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,8 @@ pub enum DbCommand {
|
||||||
pub enum DocumentCommand {
|
pub enum DocumentCommand {
|
||||||
Create { name: String },
|
Create { name: String },
|
||||||
Status { name: String },
|
Status { name: String },
|
||||||
|
Set { name: String, state_json: String },
|
||||||
|
Get { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|
@ -471,6 +473,10 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Document { command } => match command {
|
Command::Document { command } => match command {
|
||||||
DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name },
|
DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name },
|
||||||
DocumentCommand::Status { name } => ControlRequest::DocumentStatus { 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 {
|
Command::Ssh { command } => match command {
|
||||||
SshCommand::Proxy { node } => ControlRequest::ModuleStub {
|
SshCommand::Proxy { node } => ControlRequest::ModuleStub {
|
||||||
|
|
@ -904,6 +910,14 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
println!("sync_status: {}", document.sync_status);
|
println!("sync_status: {}", document.sync_status);
|
||||||
println!("state_bytes: {}", document.state_bytes);
|
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 } => {
|
ControlResponse::PubsubPublished { message } => {
|
||||||
println!("published: {}", message.topic);
|
println!("published: {}", message.topic);
|
||||||
println!("published_at_ms: {}", message.published_at.0);
|
println!("published_at_ms: {}", message.published_at.0);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use geth_auth::{AuthExplanation, AuthOp};
|
use geth_auth::{AuthExplanation, AuthOp};
|
||||||
use geth_db::DbResource;
|
use geth_db::DbResource;
|
||||||
use geth_document::DocumentResource;
|
use geth_document::{DocumentResource, DocumentState};
|
||||||
use geth_keychain::KeychainOp;
|
use geth_keychain::KeychainOp;
|
||||||
use geth_kv::{KvEntry, KvResource};
|
use geth_kv::{KvEntry, KvResource};
|
||||||
use geth_pubsub::PubsubMessage;
|
use geth_pubsub::PubsubMessage;
|
||||||
|
|
@ -138,6 +138,13 @@ pub enum ControlRequest {
|
||||||
DocumentStatus {
|
DocumentStatus {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
DocumentSet {
|
||||||
|
name: String,
|
||||||
|
state_json: String,
|
||||||
|
},
|
||||||
|
DocumentGet {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
PubsubPub {
|
PubsubPub {
|
||||||
topic: String,
|
topic: String,
|
||||||
message: String,
|
message: String,
|
||||||
|
|
@ -263,6 +270,12 @@ pub enum ControlResponse {
|
||||||
DocumentStatus {
|
DocumentStatus {
|
||||||
document: DocumentResource,
|
document: DocumentResource,
|
||||||
},
|
},
|
||||||
|
DocumentSet {
|
||||||
|
state: DocumentState,
|
||||||
|
},
|
||||||
|
DocumentGet {
|
||||||
|
state: DocumentState,
|
||||||
|
},
|
||||||
PubsubPublished {
|
PubsubPublished {
|
||||||
message: PubsubMessage,
|
message: PubsubMessage,
|
||||||
},
|
},
|
||||||
|
|
@ -384,5 +397,14 @@ mod tests {
|
||||||
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
|
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
|
||||||
response
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,6 @@ license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use geth_types::{DocumentId, ResourceId};
|
use geth_types::{DocumentId, ResourceId, UnixMillis};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -10,10 +10,19 @@ pub struct DocumentResource {
|
||||||
pub state_bytes: u64,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DocumentError {
|
pub enum DocumentError {
|
||||||
#[error("invalid document name: {0}")]
|
#[error("invalid document name: {0}")]
|
||||||
InvalidName(String),
|
InvalidName(String),
|
||||||
|
#[error("invalid document JSON state: {0}")]
|
||||||
|
InvalidState(#[from] serde_json::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_document_name(name: &str) -> Result<(), DocumentError> {
|
pub fn validate_document_name(name: &str) -> Result<(), DocumentError> {
|
||||||
|
|
@ -27,6 +36,11 @@ pub fn validate_document_name(name: &str) -> Result<(), DocumentError> {
|
||||||
Ok(())
|
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]
|
#[must_use]
|
||||||
pub fn automerge_roadmap() -> &'static str {
|
pub fn automerge_roadmap() -> &'static str {
|
||||||
"future documents use Automerge sync over Iroh with resource-local authorization"
|
"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());
|
||||||
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use geth_control::{
|
||||||
};
|
};
|
||||||
use geth_crypto::AgentKey;
|
use geth_crypto::AgentKey;
|
||||||
use geth_db::DbResource;
|
use geth_db::DbResource;
|
||||||
use geth_document::DocumentResource;
|
use geth_document::{DocumentResource, DocumentState};
|
||||||
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
||||||
use geth_keychain::{KeychainOp, KeychainOpKind};
|
use geth_keychain::{KeychainOp, KeychainOpKind};
|
||||||
use geth_kv::{KvEntry, KvResource};
|
use geth_kv::{KvEntry, KvResource};
|
||||||
|
|
@ -72,6 +72,8 @@ pub enum NodeError {
|
||||||
InvalidDocumentName(String),
|
InvalidDocumentName(String),
|
||||||
#[error("document not found: {0}")]
|
#[error("document not found: {0}")]
|
||||||
DocumentNotFound(String),
|
DocumentNotFound(String),
|
||||||
|
#[error("document error: {0}")]
|
||||||
|
Document(#[from] geth_document::DocumentError),
|
||||||
#[error("resource not found: {0}")]
|
#[error("resource not found: {0}")]
|
||||||
ResourceNotFound(String),
|
ResourceNotFound(String),
|
||||||
#[error("secrets error: {0}")]
|
#[error("secrets error: {0}")]
|
||||||
|
|
@ -826,6 +828,29 @@ pub fn handle_request(
|
||||||
document: document_resource_from_stored(&stored),
|
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 } => {
|
ControlRequest::PubsubPub { topic, message } => {
|
||||||
geth_pubsub::validate_topic(&topic)?;
|
geth_pubsub::validate_topic(&topic)?;
|
||||||
geth_pubsub::validate_message(&message)?;
|
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> {
|
fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> {
|
||||||
if store
|
if store
|
||||||
.list_resources()?
|
.list_resources()?
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@ fn document_create_and_status_use_local_store() {
|
||||||
other => panic!("unexpected response: {other:?}"),
|
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!(
|
assert!(
|
||||||
geth_node::handle_request(
|
geth_node::handle_request(
|
||||||
&node,
|
&node,
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,10 @@ loading, change extraction, and DB sync are future work.
|
||||||
through `kv create/set/get`. Iroh Documents namespaces, prefix authorization
|
through `kv create/set/get`. Iroh Documents namespaces, prefix authorization
|
||||||
enforcement, and replication are future work.
|
enforcement, and replication are future work.
|
||||||
|
|
||||||
`geth-document` currently registers local document resources with an empty JSON
|
`geth-document` currently registers local document resources and stores
|
||||||
state placeholder and local-only sync status. Automerge state, editing, and sync
|
validated JSON state in the local SQLite metadata store through
|
||||||
are future work.
|
`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
|
`geth-pubsub` currently supports local publish/subscribe snapshots through the
|
||||||
daemon control protocol. Messages live in a bounded in-memory ring buffer and
|
daemon control protocol. Messages live in a bounded in-memory ring buffer and
|
||||||
|
|
|
||||||
|
|
@ -318,8 +318,12 @@ Automerge documents.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` `geth document create/status` works for local documents.
|
- `[x]` `geth document create/status` works for local documents.
|
||||||
- `[x]` Document metadata and empty JSON state are stored durably.
|
- `[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.
|
- `[ ]` 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.
|
- `[ ]` Automerge sync over Iroh.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue