diff --git a/site/Cargo.lock b/site/Cargo.lock index 4021bb0..5b67512 100644 --- a/site/Cargo.lock +++ b/site/Cargo.lock @@ -181,6 +181,7 @@ dependencies = [ "wip-client", "wip-protocol", "wip-site-worldspace", + "wip-text-view", ] [[package]] @@ -230,6 +231,13 @@ dependencies = [ "wip-protocol", ] +[[package]] +name = "wip-text-view" +version = "0.2.0" +dependencies = [ + "wip-protocol", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/site/browser/Cargo.toml b/site/browser/Cargo.toml index 77c2d51..411dfc4 100644 --- a/site/browser/Cargo.toml +++ b/site/browser/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] wip-client = { path = "../../../wip-rs/crates/wip-client", default-features = false } wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" } +wip-text-view = { path = "../../../wip-rs/crates/wip-text-view" } wip-site-worldspace = { path = "../worldspace" } -serde_json = "1" +serde_json = { version = "1", features = ["float_roundtrip"] } wasm-bindgen = "=0.2.121" diff --git a/site/browser/src/lib.rs b/site/browser/src/lib.rs index d43faf6..d5f1617 100644 --- a/site/browser/src/lib.rs +++ b/site/browser/src/lib.rs @@ -1,4 +1,6 @@ //! Browser-owned Client/Host session. JS conversion is outside the direct WIP exchange. +mod manual; + use std::collections::BTreeMap; use serde_json::{Value as Json, json}; @@ -76,6 +78,11 @@ impl BrowserSession { let mut paths = Vec::new(); let mut pending = vec!["/".to_owned()]; while let Some(path) = pending.pop() { + // Normal document navigation is manifest-only. Dynamic Protocol Objects + // do not implement document.read and have no display path or Web URL. + if !self.display_paths.contains_key(&path) { + continue; + } if let Some(tree) = self.client.tree(&self.session, &path) { pending.extend(tree.children.iter().rev().cloned()); } diff --git a/site/browser/src/manual.rs b/site/browser/src/manual.rs new file mode 100644 index 0000000..d3e5bd2 --- /dev/null +++ b/site/browser/src/manual.rs @@ -0,0 +1,453 @@ +//! Explicit protocol API. Only Client-accepted state is projected; no document map reads. +#[cfg(test)] +mod playground_tests; +#[cfg(test)] +mod tests; +use super::*; +use wip_client::{CallOutcome, ClientError, Completion, ObservationState}; +use wip_protocol::{Documentation, InterfaceDescriptor, Object, ProtocolError, TypeExpr}; + +const MAX_TEXT_BYTES: usize = 4096; +const MAX_ARGUMENT_BYTES: usize = 64 * 1024; +const MAX_VALUE_NODES: usize = 4096; +const MAX_VALUE_DEPTH: usize = 32; + +#[derive(Debug)] +struct ManualError { + kind: &'static str, + message: String, + code: Option, +} +impl ManualError { + fn new(kind: &'static str, message: impl ToString) -> Self { + Self { + kind, + message: message.to_string(), + code: None, + } + } + fn input(message: impl ToString) -> Self { + Self::new("input", message) + } + fn client(error: ClientError) -> Self { + Self { + kind: "client", + message: error.to_string(), + code: Some(format!("{:?}", error.kind)), + } + } + fn protocol(error: ProtocolError) -> Self { + Self { + kind: "protocol", + message: error.message, + code: Some(format!("{:?}", error.code)), + } + } + fn js(self) -> JsValue { + let mut dto = json!({"kind": self.kind, "message": self.message}); + if let Some(code) = self.code { + dto["code"] = json!(code); + } + JsValue::from_str(&dto.to_string()) + } +} +type Result = std::result::Result; + +#[wasm_bindgen] +impl BrowserSession { + /// Explicit Observe only. `depth` must be an integer in 0..=32, not a JS-coerced u32. + /// The signature describes the root Object; children remain structured observation data. + pub fn observe(&mut self, path: &str, depth: f64) -> std::result::Result { + self.manual_observe(path, depth) + .map(|value| value.to_string()) + .map_err(ManualError::js) + } + + /// Explicit FetchInterface only, independent of any Object target. + pub fn fetch_interface( + &mut self, + scope: &str, + name: &str, + ) -> std::result::Result { + self.manual_interface(scope, name) + .map(|value| value.to_string()) + .map_err(ManualError::js) + } + + /// Explicit call. Never observes, fetches, retries, or converts result data to a signature. + /// Arguments are a parameter-name map of tagged Values; `{}` means no arguments. + pub fn call_operation( + &mut self, + path: &str, + scope: &str, + name: &str, + operation: &str, + args_json: &str, + ) -> std::result::Result { + self.manual_call(path, scope, name, operation, args_json) + .map(|value| value.to_string()) + .map_err(ManualError::js) + } +} + +impl BrowserSession { + fn manual_stats(&self) -> Json { + json!({"observes": self.observes, "interfaces": self.interfaces, "calls": self.calls}) + } + + // Same normal Host dispatch boundary as the existing composite APIs. Responses + // are correlated and validated before anything is exposed to JS. + fn manual_exchange( + &mut self, + request: wip_client::direct::PreparedRequest, + ) -> Result { + let id = request.id; + let response = match request.request { + Request::Observe(request) => { + self.observes += 1; + Response::Observe(self.world.observe(request)) + } + Request::FetchInterface(request) => { + self.interfaces += 1; + Response::FetchInterface(self.world.fetch_interface(request)) + } + Request::CallOperation(request) => { + self.client + .mark_dispatched(id) + .map_err(ManualError::client)?; + self.calls += 1; + Response::CallOperation(self.world.call_operation(request)) + } + }; + match self + .client + .complete(id, response) + .map_err(ManualError::client)? + { + Completion::ProtocolFailure(failure) => Err(ManualError::protocol(failure)), + completion => Ok(completion), + } + } + + fn manual_observe(&mut self, path: &str, depth: f64) -> Result { + bounded_text(path)?; + if !depth.is_finite() || depth.fract() != 0.0 || !(0.0..=32.0).contains(&depth) { + return Err(ManualError::input("depth must be an integer in 0..=32")); + } + let depth = depth as u32; + // The current Client exposes explicit refresh, not public subtree + // invalidation. Playground Observe is a deliberate fresh retrieval, + // including retained NotFound/stale children after remove/recreate. No + // operation is retried, no result is used as observation data, and the + // immutable documents retain their existing ensure/cache behavior. + let playground = self.display_paths.contains_key("/playground") + && (path == "/playground" || path.starts_with("/playground/")); + let prepared = if playground { + Some( + self.client + .refresh_observed(&self.session, path, depth) + .map_err(ManualError::client)?, + ) + } else { + self.client + .ensure_observed(&self.session, path, depth) + .map_err(ManualError::client)? + }; + let cached = prepared.is_none(); + if let Some(request) = prepared { + self.manual_exchange(request)?; + } + // ensure_* can also return None for retained errors/capacity, not only hits. + // Reconstruct requested coverage from fresh Client state, never from Host data. + let observation = self.manual_observation(path, depth)?; + let object = self.fresh_object(path)?; + let text = + wip_text_view::render_object(&object).map_err(|e| ManualError::new("render", e))?; + Ok( + json!({"path": path, "observation": observation, "textView": text, "cached": cached, "stats": self.manual_stats()}), + ) + } + + fn fresh_object(&self, path: &str) -> Result { + let entry = self + .client + .object(&self.session, path) + .ok_or_else(missing_observation)?; + fresh(&entry.state)?; + entry.object.ok_or_else(missing_observation) + } + + fn manual_observation(&self, path: &str, depth: u32) -> Result { + let object = self.fresh_object(path)?; + let children = if depth == 0 { + Json::Null + } else { + let tree = self + .client + .tree(&self.session, path) + .ok_or_else(missing_observation)?; + fresh(&tree.state)?; + Json::Array( + tree.children + .iter() + .map(|child| self.manual_observation(child, depth - 1)) + .collect::>>()?, + ) + }; + Ok(json!({"object": object_json(&object), "children": children})) + } + + fn manual_interface(&mut self, scope: &str, name: &str) -> Result { + bounded_text(scope)?; + bounded_text(name)?; + let reference = InterfaceReference { + scope: scope.into(), + name: name.into(), + }; + let prepared = self + .client + .ensure_interface(&self.session, reference.clone()) + .map_err(ManualError::client)?; + let cached = prepared.is_none(); + if let Some(request) = prepared { + self.manual_exchange(request)?; + } + let observation = self + .client + .interface(&self.session, &reference) + .ok_or_else(missing_observation)?; + fresh(&observation.state)?; + let descriptor = observation + .descriptor + .as_ref() + .ok_or_else(missing_observation)?; + let text = wip_text_view::render_interface(&wip_text_view::Interface { + reference: &reference, + descriptor, + }) + .map_err(|e| ManualError::new("render", e))?; + Ok( + json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "cached": cached, "stats": self.manual_stats()}), + ) + } + + fn manual_call( + &mut self, + path: &str, + scope: &str, + name: &str, + operation: &str, + args_json: &str, + ) -> Result { + for text in [path, scope, name, operation] { + bounded_text(text)?; + } + let args = arguments(args_json)?; + let reference = InterfaceReference { + scope: scope.into(), + name: name.into(), + }; + let prepared = self + .client + .prepare_call(&self.session, path, &reference, operation, args) + .map_err(ManualError::client)?; + let request_id = prepared.id; + let completion = self.manual_exchange(prepared)?; + let Completion::Call(record) = completion else { + return Err(missing_observation()); + }; + // Consume this exact completion, never the last unrelated history entry. + if record.request_id != request_id { + return Err(ManualError::new("client", "call correlation mismatch")); + } + match record.outcome { + CallOutcome::Success(response) => Ok( + json!({"path": path, "interface": reference_json(&reference), "operation": operation, "result": tagged_value(response.result), "stats": self.manual_stats()}), + ), + CallOutcome::ProtocolFailure(failure) => Err(ManualError::protocol(failure)), + CallOutcome::NotDispatched(failure) => Err(ManualError::client(failure)), + CallOutcome::Unknown { reason, failure } => Err(ManualError { + kind: "client", + message: format!( + "operation outcome unknown ({reason:?}): {failure:?}; do not retry automatically" + ), + code: Some("OperationOutcomeUnknown".into()), + }), + } + } +} + +fn missing_observation() -> ManualError { + ManualError { + kind: "client", + message: "fresh observation or requested coverage unavailable".into(), + code: Some("MissingObservation".into()), + } +} +fn fresh(state: &ObservationState) -> Result<()> { + match state { + ObservationState::Fresh => Ok(()), + ObservationState::Error(error) => Err(ManualError::client(error.clone())), + _ => Err(missing_observation()), + } +} +fn bounded_text(text: &str) -> Result<()> { + if text.len() > MAX_TEXT_BYTES { + Err(ManualError::input( + "path, scope, name and operation are limited to 4096 UTF-8 bytes each", + )) + } else { + Ok(()) + } +} +fn reference_json(reference: &InterfaceReference) -> Json { + json!({"scope": reference.scope, "name": reference.name}) +} +fn object_json(object: &Object) -> Json { + json!({"name": object.name, "description": object.description, "interfaces": object.interfaces.iter().map(reference_json).collect::>(), "ref": object.r#ref, "validator": object.validator}) +} +fn documentation_json(documentation: &Option) -> Json { + documentation + .as_ref() + .map(|d| json!({"summary": d.summary, "details": d.details})) + .unwrap_or(Json::Null) +} +fn descriptor_json(descriptor: &InterfaceDescriptor) -> Json { + json!({ + "format": descriptor.format, + "documentation": documentation_json(&descriptor.documentation), + "types": descriptor.types.iter().map(|t| json!({"name": t.name, "documentation": documentation_json(&t.documentation), "definition": type_json(&t.definition)})).collect::>(), + "operations": descriptor.operations.iter().map(|o| json!({"name": o.name, "documentation": documentation_json(&o.documentation), "parameters": o.parameters.iter().map(|p| json!({"name": p.name, "required": p.required, "documentation": documentation_json(&p.documentation), "type": type_json(&p.r#type)})).collect::>(), "returns": {"documentation": documentation_json(&o.returns.documentation), "type": type_json(&o.returns.r#type)}})).collect::>() + }) +} +fn type_json(value: &TypeExpr) -> Json { + match value { + TypeExpr::Unit => json!({"kind": "unit"}), + TypeExpr::Boolean => json!({"kind": "boolean"}), + TypeExpr::Integer => json!({"kind": "integer"}), + TypeExpr::Number => json!({"kind": "number"}), + TypeExpr::String => json!({"kind": "string"}), + TypeExpr::Bytes => json!({"kind": "bytes"}), + TypeExpr::Json => json!({"kind": "json"}), + TypeExpr::Entry => json!({"kind": "entry"}), + TypeExpr::Named { name } => json!({"kind": "named", "name": name}), + TypeExpr::List { items } => json!({"kind": "list", "items": type_json(items)}), + TypeExpr::Record { fields } => { + json!({"kind": "record", "fields": fields.iter().map(|f| json!({"name": f.name, "required": f.required, "documentation": documentation_json(&f.documentation), "type": type_json(&f.r#type)})).collect::>()}) + } + TypeExpr::Enum { cases } => { + json!({"kind": "enum", "cases": cases.iter().map(|c| json!({"name": c.name, "documentation": documentation_json(&c.documentation)})).collect::>()}) + } + TypeExpr::Union { cases } => { + json!({"kind": "union", "cases": cases.iter().map(|c| json!({"name": c.name, "documentation": documentation_json(&c.documentation), "payload": c.payload.as_ref().map(type_json)})).collect::>()}) + } + } +} + +fn tagged_value(value: Value) -> Json { + match value { + Value::Unit => json!({"kind": "unit"}), + Value::Boolean(value) => json!({"kind": "boolean", "value": value}), + Value::Integer(value) => json!({"kind": "integer", "value": value.to_string()}), + Value::Number(value) => json!({"kind": "number", "value": value}), + Value::String(value) => json!({"kind": "string", "value": value}), + Value::Bytes(value) => json!({"kind": "bytes", "value": value}), + Value::Record(value) => { + json!({"kind": "record", "value": value.into_iter().map(|(k,v)| (k,tagged_value(v))).collect::>()}) + } + Value::List(value) => { + json!({"kind": "list", "value": value.into_iter().map(tagged_value).collect::>()}) + } + } +} + +fn arguments(input: &str) -> Result> { + if input.len() > MAX_ARGUMENT_BYTES { + return Err(ManualError::input("arguments exceed 65536 UTF-8 bytes")); + } + // serde_json's recursion limit stays enabled, bounding parsing before our tighter Value limits. + let parsed: Json = serde_json::from_str(input).map_err(ManualError::input)?; + let map = parsed.as_object().ok_or_else(|| { + ManualError::input("arguments must be an object mapping parameter names to tagged Values") + })?; + let mut budget = MAX_VALUE_NODES; + map.iter() + .map(|(name, value)| Ok((name.clone(), parse_value(value, 1, &mut budget)?))) + .collect() +} +fn parse_value(input: &Json, depth: usize, budget: &mut usize) -> Result { + if depth > MAX_VALUE_DEPTH || *budget == 0 { + return Err(ManualError::input("Values exceed depth 32 or 4096 nodes")); + } + *budget -= 1; + let invalid = || { + ManualError::input( + "invalid tagged Value: expected kind and correctly typed value (unit has no value)", + ) + }; + let object = input.as_object().ok_or_else(invalid)?; + let kind = object + .get("kind") + .and_then(Json::as_str) + .ok_or_else(invalid)?; + if kind == "unit" { + return if object.len() == 1 { + Ok(Value::Unit) + } else { + Err(invalid()) + }; + } + if object.len() != 2 { + return Err(invalid()); + } + let value = object.get("value").ok_or_else(invalid)?; + Ok(match kind { + "boolean" => Value::Boolean(value.as_bool().ok_or_else(invalid)?), + "integer" => { + let text = value.as_str().ok_or_else(invalid)?; + let integer: i64 = text.parse().map_err(|_| invalid())?; + if integer.to_string() != text { + return Err(ManualError::input( + "integer requires a canonical signed decimal string", + )); + } + Value::Integer(integer) + } + "number" => Value::Number( + value + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(invalid)?, + ), + "string" => Value::String(value.as_str().ok_or_else(invalid)?.to_owned()), + "bytes" => Value::Bytes( + value + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|v| { + v.as_u64() + .and_then(|v| u8::try_from(v).ok()) + .ok_or_else(invalid) + }) + .collect::>()?, + ), + "record" => Value::Record( + value + .as_object() + .ok_or_else(invalid)? + .iter() + .map(|(k, v)| Ok((k.clone(), parse_value(v, depth + 1, budget)?))) + .collect::>()?, + ), + "list" => Value::List( + value + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|v| parse_value(v, depth + 1, budget)) + .collect::>()?, + ), + _ => return Err(invalid()), + }) +} diff --git a/site/browser/src/manual/playground_tests.rs b/site/browser/src/manual/playground_tests.rs new file mode 100644 index 0000000..cd8045a --- /dev/null +++ b/site/browser/src/manual/playground_tests.rs @@ -0,0 +1,243 @@ +use super::*; + +fn manifest() -> String { + json!({"nodes":[ + {"path":"/","name":"","displayPath":"/","title":"Root","href":"/","html":"

root

"}, + {"path":"/book","name":"book","parent":"/","displayPath":"/book","title":"Book","href":"/book/","html":"

book

"}, + {"path":"/playground","name":"playground","parent":"/","displayPath":"/playground","title":"Playground","href":"/playground/","html":"

playground

"} + ]}).to_string() +} +fn session() -> BrowserSession { + BrowserSession::new(&manifest()).unwrap() +} +fn increment(s: &mut BrowserSession, amount: i64) -> Result { + s.manual_call( + "/playground/apple", + "/", + "counter", + "increment", + &json!({"amount":{"kind":"integer","value":amount.to_string()}}).to_string(), + ) +} +fn collection(s: &mut BrowserSession, operation: &str, name: &str) -> Result { + s.manual_call( + "/playground", + "/", + "collection", + operation, + &json!({"name":{"kind":"string","value":name}}).to_string(), + ) +} +fn names(observed: &Json) -> Vec<&str> { + observed["observation"]["children"] + .as_array() + .unwrap() + .iter() + .map(|child| child["object"]["name"].as_str().unwrap()) + .collect() +} + +#[test] +fn explicit_observe_refreshes_counter_and_parent_but_does_not_retry_or_fill_interfaces() { + let mut s = session(); + s.manual_observe("/book", 1.0).unwrap(); + let initial = s.manual_observe("/playground", 1.0).unwrap(); + assert_eq!(names(&initial), ["apple", "orange"]); + assert_eq!( + initial["observation"]["children"][0]["object"]["description"], + "3" + ); + assert!( + increment(&mut s, 4).is_err(), + "descriptor fetch remains explicit" + ); + assert_eq!(s.calls, 0); + s.manual_interface("/", "counter").unwrap(); + let stats = s.manual_stats(); + let result = increment(&mut s, 4).unwrap(); + assert_eq!(result["result"], json!({"kind":"integer","value":"7"})); + assert!(result.get("observation").is_none()); + assert!(result.get("textView").is_none()); + assert_eq!(s.observes, stats["observes"].as_u64().unwrap() as u32); + assert_eq!(s.interfaces, stats["interfaces"].as_u64().unwrap() as u32); + // A result did not silently fabricate a refreshed Client Object. + assert_eq!( + s.client + .object(&s.session, "/playground/apple") + .unwrap() + .object + .unwrap() + .description + .as_deref(), + Some("3") + ); + let parent = s.manual_observe("/playground", 1.0).unwrap(); + assert_eq!(parent["cached"], false); + assert_eq!( + parent["observation"]["children"][0]["object"]["name"], + "apple" + ); + assert_eq!( + parent["observation"]["children"][0]["object"]["description"], + "7" + ); + let selected = s.manual_observe("/playground/apple", 1.0).unwrap(); + assert_eq!(selected["cached"], false); + assert_eq!(selected["observation"]["object"]["description"], "7"); + assert_eq!( + selected["textView"], + wip_text_view::render_object(&s.fresh_object("/playground/apple").unwrap()).unwrap() + ); + assert_eq!(s.manual_observe("/book", 1.0).unwrap()["cached"], true); + assert_eq!(s.manual_interface("/", "counter").unwrap()["cached"], true); + assert_eq!(s.calls, 1); +} + +#[test] +fn collection_refresh_removes_stale_children_and_recovers_from_retained_not_found() { + let mut s = session(); + s.manual_observe("/playground", 2.0).unwrap(); + s.manual_interface("/", "collection").unwrap(); + s.manual_interface("/", "counter").unwrap(); + assert_eq!( + collection(&mut s, "create", "pear").unwrap()["result"], + json!({"kind":"unit"}) + ); + assert_eq!( + names(&s.manual_observe("/playground", 1.0).unwrap()), + ["apple", "orange", "pear"] + ); + collection(&mut s, "remove", "apple").unwrap(); + assert_eq!( + names(&s.manual_observe("/playground", 1.0).unwrap()), + ["orange", "pear"] + ); + assert_eq!( + s.manual_observe("/playground/apple", 1.0) + .unwrap_err() + .code + .as_deref(), + Some("NotFound") + ); + collection(&mut s, "create", "apple").unwrap(); + // Explicit fresh refresh recovers even from a retained Client NotFound. + let selected = s.manual_observe("/playground/apple", 1.0).unwrap(); + assert_eq!(selected["observation"]["object"]["description"], "0"); + assert_eq!( + names(&s.manual_observe("/playground", 1.0).unwrap()), + ["apple", "orange", "pear"] + ); + assert_eq!(increment(&mut s, 2).unwrap()["result"]["value"], "2"); + s.manual_observe("/playground", 1.0).unwrap(); + s.manual_call("/playground/apple", "/", "counter", "reset", "{}") + .unwrap(); + assert_eq!( + s.manual_observe("/playground", 1.0).unwrap()["observation"]["children"][0]["object"]["description"], + "0" + ); +} + +#[test] +fn failures_are_atomic_and_each_explicit_call_dispatches_at_most_once() { + let mut s = session(); + s.manual_observe("/playground", 2.0).unwrap(); + s.manual_interface("/", "counter").unwrap(); + s.manual_interface("/", "collection").unwrap(); + increment(&mut s, wip_protocol::MAX_SAFE_INTEGER - 3).unwrap(); + let maximum = s.manual_observe("/playground/apple", 1.0).unwrap(); + let calls = s.calls; + let failure = increment(&mut s, 1).unwrap_err(); + assert_eq!(failure.kind, "protocol"); + assert_eq!(failure.code.as_deref(), Some("Internal")); + assert_eq!(s.calls, calls + 1); + assert_eq!( + s.manual_observe("/playground/apple", 1.0).unwrap()["observation"], + maximum["observation"] + ); + for name in ["apple", "bad/name", "", ""] { + let before = s.manual_observe("/playground", 1.0).unwrap(); + let calls = s.calls; + assert!(collection(&mut s, "create", name).is_err()); + assert_eq!(s.calls, calls + 1); + assert_eq!( + s.manual_observe("/playground", 1.0).unwrap()["observation"], + before["observation"] + ); + } + let calls = s.calls; + assert!(increment(&mut s, i64::MAX).is_err()); + assert_eq!( + s.calls, calls, + "out-of-range typed arguments rejected before Host dispatch" + ); +} + +#[test] +fn normal_tree_and_document_reads_ignore_dynamic_children_and_reload_resets_state() { + let mut s = session(); + let tree = s.tree().unwrap(); + let rows: Json = serde_json::from_str(&tree).unwrap(); + assert_eq!( + rows.as_array() + .unwrap() + .iter() + .map(|row| row["path"].as_str().unwrap()) + .collect::>(), + ["/", "/book", "/playground"] + ); + let document = s.read("/playground").unwrap(); + let book = s.read("/book").unwrap(); + s.manual_interface("/", "counter").unwrap(); + s.manual_interface("/", "collection").unwrap(); + increment(&mut s, 10).unwrap(); + collection(&mut s, "create", "pear").unwrap(); + collection(&mut s, "remove", "orange").unwrap(); + s.manual_observe("/playground", 1.0).unwrap(); + assert_eq!(s.tree().unwrap(), tree); + assert_eq!(s.read("/playground").unwrap(), document); + assert_eq!(s.read("/book").unwrap(), book); + assert_eq!( + s.manual_observe("/playground/apple", 1.0).unwrap()["observation"]["object"]["description"], + "13", + "navigation does not reset state" + ); + let mut reloaded = session(); + let initial = reloaded.manual_observe("/playground", 1.0).unwrap(); + assert_eq!(names(&initial), ["apple", "orange"]); + assert_eq!( + initial["observation"]["children"][0]["object"]["description"], + "3" + ); + assert_eq!( + initial["observation"]["children"][1]["object"]["description"], + "0" + ); +} + +#[test] +fn old_manifests_do_not_enable_playground_or_change_cached_observation_behavior() { + let mut data: Json = serde_json::from_str(&manifest()).unwrap(); + data["nodes"].as_array_mut().unwrap().pop(); + let mut s = BrowserSession::new(&data.to_string()).unwrap(); + assert_eq!( + s.manual_observe("/playground", 1.0) + .unwrap_err() + .code + .as_deref(), + Some("NotFound") + ); + let stats = s.manual_stats(); + assert_eq!( + s.manual_observe("/playground", 1.0).unwrap_err().kind, + "client" + ); + assert_eq!(s.manual_stats(), stats); + for name in ["collection", "counter"] { + assert_eq!( + s.manual_interface("/", name).unwrap_err().code.as_deref(), + Some("InterfaceNotFound") + ); + } + s.manual_observe("/", 1.0).unwrap(); + assert_eq!(s.manual_observe("/", 1.0).unwrap()["cached"], true); +} diff --git a/site/browser/src/manual/tests.rs b/site/browser/src/manual/tests.rs new file mode 100644 index 0000000..a519abe --- /dev/null +++ b/site/browser/src/manual/tests.rs @@ -0,0 +1,326 @@ +use super::*; + +fn manifest() -> String { + json!({"nodes": [ + {"path":"/", "name":"", "displayPath":"/", "title":"Root", "href":"/", "html":"

root body

"}, + {"path":"/quoted", "name":"quoted", "displayPath":"/quoted", "parent":"/", "title":" 引用 \"quote\" \\ space\nnext\tline ", "href":"/quoted/", "html":""} + ]}).to_string() +} +fn session() -> BrowserSession { + BrowserSession::new(&manifest()).unwrap() +} + +#[test] +fn manual_exchanges_do_not_read_until_explicit_call_and_cache_only_fresh_state() { + let mut s = session(); + let observed = s.manual_observe("/", 1.0).unwrap(); + assert_eq!( + observed["stats"], + json!({"observes":1,"interfaces":0,"calls":0}) + ); + assert_eq!(observed["cached"], false); + assert_eq!( + observed["observation"]["children"][0]["children"], + Json::Null + ); + assert!(!observed.to_string().contains("root body")); + let cached = s.manual_observe("/", 1.0).unwrap(); + assert_eq!(cached["observation"], observed["observation"]); + assert_eq!(cached["cached"], true); + assert_eq!(cached["stats"], observed["stats"]); + let deeper = s.manual_observe("/", 2.0).unwrap(); + assert_eq!(deeper["cached"], false); + assert_eq!(deeper["observation"]["children"][0]["children"], json!([])); + let fetched = s.manual_interface("/", "document").unwrap(); + assert_eq!( + fetched["stats"], + json!({"observes":2,"interfaces":1,"calls":0}) + ); + assert_eq!(fetched["cached"], false); + assert!(!fetched.to_string().contains("root body")); + assert_eq!(s.manual_interface("/", "document").unwrap()["cached"], true); + let result = s + .manual_call("/quoted", "/", "document", "read", "{}") + .unwrap(); + assert_eq!( + result["stats"], + json!({"observes":2,"interfaces":1,"calls":1}) + ); + assert_eq!(result["path"], "/quoted"); + assert_eq!(result["interface"], json!({"scope":"/", "name":"document"})); + assert_eq!(result["result"]["kind"], "record"); + assert_eq!( + result["result"]["value"]["path"], + json!({"kind":"string","value":"/quoted"}) + ); + assert_eq!( + result["result"]["value"]["html"], + json!({"kind":"string","value":""}) + ); + assert!(result.get("textView").is_none()); + s.manual_call("/quoted", "/", "document", "read", "{}") + .unwrap(); + assert_eq!(s.calls, 2); +} + +#[test] +fn calls_do_not_fill_preconditions_or_retry_failures() { + let mut s = session(); + let error = s + .manual_call("/", "/", "document", "read", "{}") + .unwrap_err(); + assert_eq!(error.kind, "client"); + assert_eq!(error.code.as_deref(), Some("MissingObservation")); + assert_eq!( + s.manual_stats(), + json!({"observes":0,"interfaces":0,"calls":0}) + ); + s.manual_observe("/", 0.0).unwrap(); + assert_eq!( + s.manual_call("/", "/", "document", "read", "{}") + .unwrap_err() + .code + .as_deref(), + Some("MissingObservation") + ); + assert_eq!(s.interfaces, 0); + s.manual_interface("/", "document").unwrap(); + for (name, operation, args) in [ + ("document", "missing", "{}"), + ("other", "read", "{}"), + ( + "document", + "read", + r#"{"extra":{"kind":"string","value":"x"}}"#, + ), + ] { + assert_eq!( + s.manual_call("/", "/", name, operation, args) + .unwrap_err() + .kind, + "client" + ); + } + assert_eq!(s.calls, 0); + assert_eq!( + s.manual_observe("/absent", 0.0) + .unwrap_err() + .code + .as_deref(), + Some("NotFound") + ); + assert_eq!( + s.manual_interface("/", "absent") + .unwrap_err() + .code + .as_deref(), + Some("InterfaceNotFound") + ); + let stats = s.manual_stats(); + assert_eq!(s.manual_observe("/absent", 0.0).unwrap_err().kind, "client"); + assert_eq!( + s.manual_interface("/", "absent").unwrap_err().kind, + "client" + ); + assert_eq!( + s.manual_stats(), + stats, + "retained failures are not cache successes or retries" + ); +} + +#[test] +fn fetch_is_independent_and_sessions_do_not_share_observations() { + let mut normal = session(); + normal.tree().unwrap(); + let mut manual = session(); + let fetch = manual.manual_interface("/", "document").unwrap(); + assert_eq!( + fetch["stats"], + json!({"observes":0,"interfaces":1,"calls":0}) + ); + assert_eq!( + manual + .manual_call("/", "/", "document", "read", "{}") + .unwrap_err() + .code + .as_deref(), + Some("MissingObservation") + ); + assert_eq!(manual.calls, 0); + manual.manual_observe("/", 0.0).unwrap(); + assert_eq!( + manual + .manual_call("/", "/", "document", "read", "{}") + .unwrap()["result"]["value"]["path"]["value"], + "/" + ); +} + +#[test] +fn signatures_are_real_renderer_output_and_exclude_metadata() { + let mut s = session(); + let observed = s.manual_observe("/quoted", 0.0).unwrap(); + let mut object = s.fresh_object("/quoted").unwrap(); + assert_eq!( + observed["textView"], + wip_text_view::render_object(&object).unwrap() + ); + let text = observed["textView"].as_str().unwrap(); + assert!(text.contains("引用 \\\"quote\\\" \\\\ space\\nnext\\tline ")); + assert!(text.ends_with("}\n")); + object.r#ref = Some("secret metadata".into()); + object.validator = Some(vec![1, 2, 3]); + assert_eq!( + observed["textView"], + wip_text_view::render_object(&object).unwrap() + ); + let fetched = s.manual_interface("/", "document").unwrap(); + let reference = Worldspace::document_interface(); + let observation = s.client.interface(&s.session, &reference).unwrap(); + let descriptor = observation.descriptor.as_ref().unwrap(); + assert_eq!( + fetched["textView"], + wip_text_view::render_interface(&wip_text_view::Interface { + reference: &reference, + descriptor + }) + .unwrap() + ); + assert_eq!(fetched["descriptor"]["operations"][0]["name"], "read"); + assert_eq!( + fetched["descriptor"]["operations"][0]["parameters"], + json!([]) + ); +} + +#[test] +fn all_tagged_values_round_trip_without_integer_or_bytes_coercion() { + let values = vec![ + Value::Unit, + Value::Boolean(true), + Value::Integer(i64::MIN), + Value::Integer(i64::MAX), + Value::Number(-0.0), + Value::Number(1.25), + Value::String("引用\n\t\"".into()), + Value::Bytes(vec![0, 255]), + Value::Record(BTreeMap::from([("__proto__".into(), Value::Integer(1))])), + Value::List(vec![Value::Unit, Value::String("x".into())]), + ]; + for value in values { + let dto = tagged_value(value.clone()); + let encoded = json!({"arg": dto}).to_string(); + let parsed = arguments(&encoded).unwrap(); + assert_eq!(parsed["arg"], value); + if let Value::Number(n) = value { + let Value::Number(actual) = parsed["arg"] else { + panic!() + }; + assert_eq!(n.to_bits(), actual.to_bits()); + } + } + assert_eq!(arguments("{}").unwrap(), BTreeMap::new()); + for input in [ + "null", + "[]", + "{", + r#"{"x":1}"#, + r#"{"x":{"kind":"integer","value":9007199254740993}}"#, + r#"{"x":{"kind":"integer","value":"01"}}"#, + r#"{"x":{"kind":"bytes","value":[256]}}"#, + r#"{"x":{"kind":"unit","value":null}}"#, + r#"{"x":{"kind":"number","value":1e400}}"#, + r#"{"x":{"kind":"unknown"}}"#, + ] { + assert_eq!(arguments(input).unwrap_err().kind, "input", "{input}"); + } + // DTO conversion can represent all i64, but Protocol Integer remains JS-safe. + let descriptor = Worldspace::new(serde_json::from_str(&manifest()).unwrap()) + .unwrap() + .fetch_interface(wip_protocol::FetchInterfaceRequest { + interface: Worldspace::document_interface(), + }) + .unwrap() + .descriptor; + assert!( + descriptor + .validate_value(&TypeExpr::Integer, &Value::Integer(i64::MAX)) + .is_err() + ); +} + +#[test] +fn bounds_fail_before_dispatch_and_render_failures_are_distinct() { + let mut s = session(); + for depth in [-1.0, 0.5, 33.0, f64::NAN, f64::INFINITY, 4294967296.0] { + assert_eq!(s.manual_observe("/", depth).unwrap_err().kind, "input"); + } + assert_eq!( + s.manual_observe(&"/".repeat(MAX_TEXT_BYTES + 1), 0.0) + .unwrap_err() + .kind, + "input" + ); + assert_eq!( + s.manual_observe("relative", 0.0).unwrap_err().kind, + "client" + ); + assert_eq!( + arguments(&" ".repeat(MAX_ARGUMENT_BYTES + 1)) + .unwrap_err() + .kind, + "input" + ); + let mut nested = json!({"kind":"unit"}); + for _ in 0..MAX_VALUE_DEPTH { + nested = json!({"kind":"list", "value":[nested]}); + } + assert_eq!( + arguments(&json!({"arg":nested}).to_string()) + .unwrap_err() + .kind, + "input" + ); + let mut budget = 1; + assert_eq!( + parse_value( + &json!({"kind":"list","value":[{"kind":"unit"}]}), + 1, + &mut budget + ) + .unwrap_err() + .kind, + "input" + ); + assert_eq!( + s.manual_stats(), + json!({"observes":0,"interfaces":0,"calls":0}) + ); + let mut data: Json = serde_json::from_str(&manifest()).unwrap(); + data["nodes"][0]["title"] = json!("x".repeat(1024 * 1024 + 1)); + let mut large = BrowserSession::new(&data.to_string()).unwrap(); + assert_eq!(large.manual_observe("/", 0.0).unwrap_err().kind, "render"); + assert_eq!( + large.manual_stats(), + json!({"observes":1,"interfaces":0,"calls":0}) + ); +} + +#[test] +fn host_call_failure_is_typed_and_counted_once() { + let mut s = session(); + s.manual_observe("/quoted", 0.0).unwrap(); + s.manual_interface("/", "document").unwrap(); + // Simulate a publication disappearing after observation: real Host dispatch, + // not a fabricated Client response or a document-map shortcut. + let mut data: WorldspaceData = serde_json::from_str(&manifest()).unwrap(); + data.nodes.pop(); + s.world = Worldspace::new(data).unwrap(); + let error = s + .manual_call("/quoted", "/", "document", "read", "{}") + .unwrap_err(); + assert_eq!(error.kind, "protocol"); + assert_eq!(error.code.as_deref(), Some("NotFound")); + assert_eq!(s.calls, 1); +} diff --git a/site/worldspace/README.md b/site/worldspace/README.md index 294232e..113075d 100644 --- a/site/worldspace/README.md +++ b/site/worldspace/README.md @@ -1,6 +1,7 @@ # Public-document Worldspace -`wip-site-worldspace` is the site's immutable in-memory Host provider. It uses +`wip-site-worldspace` is the site's in-memory Host provider: immutable documents +plus an opt-in session-local playground. It uses `wip-host`, `wip-interface` and `wip-protocol`; it does not depend on the filesystem server, HTTP, a browser, or a network runtime. The native and WASM callers use the same `Worldspace` instance API. @@ -94,6 +95,39 @@ validates the assembled response against the request. At the requested depth, Construction allows at most `MAX_NODES = 4096`, with hierarchy depth at most 32. `from_json` also caps input at 16 MiB. Together these bounds cap traversal work. +## Playground + +A manifest document at `/playground` opts into mutable state. Manifests without +that node retain document-only behavior. Manifest descendants of `/playground` +are rejected, so mutable counters cannot shadow or delete document content. + +- `/playground` keeps `/::document.read()` and also exposes `/::collection`: + `create(name: String) -> Unit` creates a zero-valued counter; + `remove(name: String) -> Unit` removes an existing counter. +- Children start as `apple = 3` and `orange = 0`, ordered lexically. Each exposes + only `/::counter`: `get() -> Integer`, `increment(amount: Integer) -> Integer`, + and `reset() -> Integer`. Increment returns the new value; reset returns zero. +- Names match `[A-Za-z0-9_-]{1,32}` and are case-sensitive. At most 32 counters + exist. Duplicate creation, missing removal, invalid names, and checked sums + outside Protocol Integer's range `[-9007199254740991, 9007199254740991]` fail + without mutation. Capacity errors are `ResourceLimitExceeded`; domain + rejections are `Internal` with an explicit not-committed Host guarantee. + Invalid typed arguments are rejected by standard Client/Host validation. +- Every counter lookup publishes a fresh Object snapshot whose `description` + is the current decimal integer and whose `name` remains the slug. All + generated interfaces bind to the same actual root ScopeObservation. +- State belongs to one Worldspace/BrowserSession. Navigation retains it; a new + session restores initial state. Documents are never mutated. + +The browser's normal `tree()` includes only manifest display paths and therefore +never calls `document.read` on dynamic counters. Manual playground `observe` +uses the real Client's `refresh_observed` on every explicit retrieval (including +recovery from retained missing/stale observations); other documents retain +`ensure_observed` caching. This Client version has no public subtree-invalidation +API. A call does not observe, fetch, retry, or convert its result into an Object. +The UI explicitly observes `/playground` at depth 1, then the selected Object at +depth 1 after calls, and observes `/playground` on entry to Protocol mode. + ## Validation From the site directory, with the configured Rust toolchain and dependencies: diff --git a/site/worldspace/src/lib.rs b/site/worldspace/src/lib.rs index d97cdf4..6082e90 100644 --- a/site/worldspace/src/lib.rs +++ b/site/worldspace/src/lib.rs @@ -1,15 +1,19 @@ -//! Immutable, site-specific Worldspace. All three interactions use the ordinary -//! Host publication boundary; only bounded recursive observation is site logic. +//! Site documents and an opt-in, session-local playground. All interactions use +//! the ordinary typed Host publication boundary. +mod playground; +pub use playground::{Collection, Counter, MAX_COUNTERS, PLAYGROUND_PATH}; +use playground::{CollectionDefinition, CounterDefinition, Playground}; use std::collections::{BTreeMap, HashSet}; use std::error::Error; use std::fmt::{Display, Formatter}; +use std::rc::Rc; use std::sync::Arc; use serde::{Deserialize, Serialize}; use wip_host::{ - ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject, Scope, - ScopeObservation, WipObject, + ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject, + RegisteredInterface, Scope, ScopeObservation, WipObject, }; use wip_interface::{OperationResult, WipType, interface}; pub use wip_protocol; @@ -62,25 +66,44 @@ pub trait Document { } /// Host-private implementation; no body lookup bypass is exposed to clients. -pub struct SiteObject(Arc); +pub enum SiteObject { + Document(Arc, Option>), + Counter { + name: String, + description: String, + state: Rc, + }, +} impl WipObject for SiteObject { fn name(&self) -> &str { - &self.0.name + match self { + Self::Document(node, _) => &node.name, + Self::Counter { name, .. } => name, + } } fn description(&self) -> Option<&str> { - Some(&self.0.title) + Some(match self { + Self::Document(node, _) => &node.title, + Self::Counter { description, .. } => description, + }) } } impl Document for SiteObject { fn read(&self) -> OperationResult { + let Self::Document(node, _) = self else { + return Err(wip_interface::HostError::internal( + "not a document", + wip_interface::EffectGuarantee::NotCommitted, + )); + }; Ok(DocumentContent { - path: self.0.path.clone(), - title: self.0.title.clone(), - href: self.0.href.clone(), - html: self.0.html.clone(), + path: node.path.clone(), + title: node.title.clone(), + href: node.href.clone(), + html: node.html.clone(), }) } } @@ -106,12 +129,15 @@ fn protocol_error(code: ProtocolErrorCode, message: impl Into) -> Protoc } } -/// An immutable publication snapshot, shared by native and WASM callers. -/// Construction is the only mutation boundary; later interactions require only `&self`. +/// Immutable documents and isolated mutable playground state. This synchronous, +/// single-threaded provider publishes a fresh counter snapshot on every lookup. pub struct Worldspace { registry: InterfaceRegistry, objects: BTreeMap>, children: BTreeMap>, + playground: Option>, + counter: Option>, + current_scope: ScopeObservation, } impl Worldspace { @@ -130,6 +156,37 @@ impl Worldspace { let document = registry .register(&scope, DOCUMENT_INTERFACE, DocumentDefinition, None) .map_err(|error| invalid(error.to_string()))?; + let playground = data + .nodes + .iter() + .any(|node| node.path == PLAYGROUND_PATH) + .then(|| Rc::new(Playground::new())); + let (collection, counter) = if playground.is_some() { + // The mutable namespace cannot shadow or delete manifest documents. + if data + .nodes + .iter() + .any(|node| node.path.starts_with("/playground/")) + { + return Err(invalid( + "playground children are reserved for session counters", + )); + } + ( + Some( + registry + .register(&scope, "collection", CollectionDefinition, None) + .map_err(|error| invalid(error.to_string()))?, + ), + Some( + registry + .register(&scope, "counter", CounterDefinition, None) + .map_err(|error| invalid(error.to_string()))?, + ), + ) + } else { + (None, None) + }; // Every publication attests the same actual, immutable root Object. // Both fetch and calls need this association; a registry token alone is insufficient. let root = Arc::new( @@ -139,7 +196,8 @@ impl Worldspace { .unwrap() .clone(), ); - let current_scope = ScopeObservation::new(&scope, &SiteObject(Arc::clone(&root))); + let current_scope = + ScopeObservation::new(&scope, &SiteObject::Document(Arc::clone(&root), None)); let mut objects = BTreeMap::new(); let mut children: BTreeMap> = BTreeMap::new(); for node in data.nodes { @@ -150,21 +208,36 @@ impl Worldspace { .or_default() .push(path.clone()); } - let object = SiteObject(if path == "/" { - Arc::clone(&root) - } else { - Arc::new(node) - }); - let publication = PublishedObject::new(object) + let object = SiteObject::Document( + if path == "/" { + Arc::clone(&root) + } else { + Arc::new(node) + }, + if path == PLAYGROUND_PATH { + playground.clone() + } else { + None + }, + ); + let mut publication = PublishedObject::new(object) .bind_scope(current_scope.clone()) .expose(&document) .map_err(|error| invalid(error.to_string()))?; + if path == PLAYGROUND_PATH { + publication = publication + .expose(collection.as_ref().unwrap()) + .map_err(|error| invalid(error.to_string()))?; + } objects.insert(path, publication); } Ok(Self { registry, objects, children, + playground, + counter, + current_scope, }) } @@ -194,10 +267,8 @@ impl Worldspace { None } else { Some( - self.children - .get(path) - .into_iter() - .flatten() + self.child_paths(path) + .iter() .map(|child| self.observe_at(child, depth - 1)) .collect::, _>>()?, ) @@ -205,6 +276,20 @@ impl Worldspace { Ok(ObjectObservation { object, children }) } + fn child_paths(&self, path: &str) -> Vec { + if path == PLAYGROUND_PATH + && let Some(state) = &self.playground + { + return state + .counters + .borrow() + .keys() + .map(|name| format!("{PLAYGROUND_PATH}/{name}")) + .collect(); + } + self.children.get(path).cloned().unwrap_or_default() + } + pub fn fetch_interface( &self, request: FetchInterfaceRequest, @@ -237,6 +322,33 @@ impl ObjectProvider for Worldspace { path: &str, operation: impl FnOnce(&PublishedObject) -> R, ) -> ProviderResult { + if let (Some(state), Some(counter), Some(name)) = ( + &self.playground, + &self.counter, + path.strip_prefix("/playground/"), + ) { + let value = state.counters.borrow().get(name).copied(); + let Some(value) = value else { + return ProviderResult::NotFound; + }; + // No shared mutable description: each callback receives its own current + // snapshot. Rc/RefCell makes the synchronous lookup/dispatch boundary + // single-threaded, with no external writer between resolution and call. + let publication = PublishedObject::new(SiteObject::Counter { + name: name.into(), + description: value.to_string(), + state: Rc::clone(state), + }) + .bind_scope(self.current_scope.clone()) + .expose(counter); + return match publication { + Ok(publication) => ProviderResult::Found(operation(&publication)), + Err(error) => ProviderResult::Failure(wip_interface::HostError::internal( + error.to_string(), + wip_interface::EffectGuarantee::NotCommitted, + )), + }; + } match self.objects.get(path) { Some(publication) => ProviderResult::Found(operation(publication)), None => ProviderResult::NotFound, diff --git a/site/worldspace/src/playground.rs b/site/worldspace/src/playground.rs new file mode 100644 index 0000000..0de9ac7 --- /dev/null +++ b/site/worldspace/src/playground.rs @@ -0,0 +1,136 @@ +//! Bounded in-memory state behind generated, typed Interface dispatch. +use std::cell::RefCell; +use std::collections::BTreeMap; + +use wip_interface::{EffectGuarantee, HostError, OperationResult, interface}; +use wip_protocol::{MAX_SAFE_INTEGER, MIN_SAFE_INTEGER}; + +use crate::SiteObject; + +pub const PLAYGROUND_PATH: &str = "/playground"; +pub const MAX_COUNTERS: usize = 32; + +#[interface] +pub trait Collection { + /// Creates a zero-valued counter with a unique ASCII slug (1..=32 characters). + fn create(&self, name: String) -> OperationResult<()>; + /// Removes an existing counter. + fn remove(&self, name: String) -> OperationResult<()>; +} + +#[interface] +pub trait Counter { + /// Returns the current counter value. + fn get(&self) -> OperationResult; + /// Adds a signed amount, failing without mutation outside Protocol Integer range. + fn increment(&self, amount: i64) -> OperationResult; + /// Resets the counter to zero and returns zero. + fn reset(&self) -> OperationResult; +} + +pub struct Playground { + pub(crate) counters: RefCell>, +} + +impl Playground { + pub fn new() -> Self { + Self { + counters: RefCell::new(BTreeMap::from([("apple".into(), 3), ("orange".into(), 0)])), + } + } +} + +fn rejected(message: &str) -> HostError { + HostError::internal(message, EffectGuarantee::NotCommitted) +} + +fn validate_name(name: &str) -> OperationResult<()> { + if !(1..=32).contains(&name.len()) + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(rejected( + "counter names must be ASCII slugs of length 1..=32", + )); + } + Ok(()) +} + +impl Collection for SiteObject { + fn create(&self, name: String) -> OperationResult<()> { + validate_name(&name)?; + let Self::Document(_, Some(state)) = self else { + return Err(rejected("not a collection")); + }; + let mut counters = state.counters.borrow_mut(); + if counters.contains_key(&name) { + return Err(rejected("counter already exists")); + } + if counters.len() >= MAX_COUNTERS { + return Err(HostError::resource_limit( + "counter limit reached", + EffectGuarantee::NotCommitted, + )); + } + counters.insert(name, 0); + Ok(()) + } + + fn remove(&self, name: String) -> OperationResult<()> { + validate_name(&name)?; + let Self::Document(_, Some(state)) = self else { + return Err(rejected("not a collection")); + }; + let mut counters = state.counters.borrow_mut(); + if !counters.contains_key(&name) { + return Err(rejected("counter does not exist")); + } + counters.remove(&name); + Ok(()) + } +} + +impl Counter for SiteObject { + fn get(&self) -> OperationResult { + let Self::Counter { name, state, .. } = self else { + return Err(rejected("not a counter")); + }; + state + .counters + .borrow() + .get(name) + .copied() + .ok_or_else(|| rejected("counter does not exist")) + } + + fn increment(&self, amount: i64) -> OperationResult { + let Self::Counter { name, state, .. } = self else { + return Err(rejected("not a counter")); + }; + let mut counters = state.counters.borrow_mut(); + let value = counters + .get_mut(name) + .ok_or_else(|| rejected("counter does not exist"))?; + // Validate before commit, including the generated codec's result range. + // Otherwise dispatch could fail to encode a result after changing state. + let next = value + .checked_add(amount) + .filter(|next| (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(next)) + .ok_or_else(|| rejected("counter exceeds Protocol Integer range"))?; + *value = next; + Ok(next) + } + + fn reset(&self) -> OperationResult { + let Self::Counter { name, state, .. } = self else { + return Err(rejected("not a counter")); + }; + let mut counters = state.counters.borrow_mut(); + let value = counters + .get_mut(name) + .ok_or_else(|| rejected("counter does not exist"))?; + *value = 0; + Ok(0) + } +} diff --git a/site/worldspace/tests/playground.rs b/site/worldspace/tests/playground.rs new file mode 100644 index 0000000..0464b09 --- /dev/null +++ b/site/worldspace/tests/playground.rs @@ -0,0 +1,383 @@ +use std::collections::BTreeMap; + +use wip_site_worldspace::wip_protocol::{ + CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceReference, + InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, ObjectObservation, ObserveRequest, + ProtocolError, ProtocolErrorCode, Target, TypeExpr, Value, +}; +use wip_site_worldspace::{MAX_COUNTERS, Worldspace}; + +fn world() -> Worldspace { + Worldspace::from_json(r#"{"nodes":[ + {"path":"/","name":"","displayPath":"/","title":"Root","href":"/","html":"

root

"}, + {"path":"/book","name":"book","parent":"/","displayPath":"/book","title":"Book","href":"/book/","html":"

book

"}, + {"path":"/playground","name":"playground","parent":"/","displayPath":"/playground","title":"Playground","href":"/playground/","html":"

playground

"} + ]}"#).unwrap() +} + +fn reference(name: &str) -> InterfaceReference { + InterfaceReference { + scope: "/".into(), + name: name.into(), + } +} + +fn call( + world: &Worldspace, + path: &str, + interface: &str, + operation: &str, + arguments: BTreeMap, +) -> Result { + world.call_operation(CallOperationRequest { + target: Target { + path: path.into(), + validator: None, + }, + interface: InterfaceTarget { + reference: reference(interface), + scope_ref: None, + validator: None, + }, + operation: operation.into(), + arguments, + }) +} +fn collection( + world: &Worldspace, + operation: &str, + name: &str, +) -> Result { + call( + world, + "/playground", + "collection", + operation, + BTreeMap::from([("name".into(), Value::String(name.into()))]), + ) +} +fn increment(world: &Worldspace, amount: i64) -> Result { + call( + world, + "/playground/apple", + "counter", + "increment", + BTreeMap::from([("amount".into(), Value::Integer(amount))]), + ) +} +fn observe(world: &Worldspace, path: &str, depth: u32) -> ObjectObservation { + world + .observe(ObserveRequest { + path: path.into(), + depth, + }) + .unwrap() +} + +#[test] +fn typed_descriptors_root_scope_and_initial_snapshots() { + let world = world(); + let collection = world + .fetch_interface(FetchInterfaceRequest { + interface: reference("collection"), + }) + .unwrap(); + let counter = world + .fetch_interface(FetchInterfaceRequest { + interface: reference("counter"), + }) + .unwrap(); + assert_eq!( + collection + .descriptor + .operations + .iter() + .map(|op| op.name.as_str()) + .collect::>(), + ["create", "remove"] + ); + for op in &collection.descriptor.operations { + assert_eq!(op.parameters.len(), 1); + assert_eq!(op.parameters[0].name, "name"); + assert_eq!(op.parameters[0].r#type, TypeExpr::String); + assert_eq!(op.returns.r#type, TypeExpr::Unit); + } + assert_eq!( + counter + .descriptor + .operations + .iter() + .map(|op| op.name.as_str()) + .collect::>(), + ["get", "increment", "reset"] + ); + assert_eq!( + counter.descriptor.operations[1].parameters[0].name, + "amount" + ); + assert_eq!( + counter.descriptor.operations[1].parameters[0].r#type, + TypeExpr::Integer + ); + assert!( + counter + .descriptor + .operations + .iter() + .all(|op| op.returns.r#type == TypeExpr::Integer) + ); + let initial = observe(&world, "/playground", 2); + assert_eq!( + initial.object.interfaces, + [reference("document"), reference("collection")] + ); + let children = initial.children.unwrap(); + assert_eq!( + children + .iter() + .map(|c| (c.object.name.as_str(), c.object.description.as_deref())) + .collect::>(), + [("apple", Some("3")), ("orange", Some("0"))] + ); + assert!( + children + .iter() + .all(|c| c.object.interfaces == [reference("counter")] && c.children == Some(vec![])) + ); + assert_eq!( + call( + &world, + "/playground/apple", + "counter", + "get", + BTreeMap::new() + ) + .unwrap() + .result, + Value::Integer(3) + ); + assert_eq!( + call( + &world, + "/playground/apple", + "document", + "read", + BTreeMap::new() + ) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceMismatch + ); + assert_eq!( + call(&world, "/book", "collection", "create", BTreeMap::new()) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceMismatch + ); +} + +#[test] +fn mutations_publish_new_values_and_children_without_mutating_documents() { + let world = world(); + let before = observe(&world, "/playground/apple", 1); + let documents: Vec<_> = ["/", "/book", "/playground"] + .into_iter() + .map(|path| { + ( + path, + call(&world, path, "document", "read", BTreeMap::new()) + .unwrap() + .result, + ) + }) + .collect(); + assert_eq!(increment(&world, 7).unwrap().result, Value::Integer(10)); + assert_eq!( + observe(&world, "/playground/apple", 1) + .object + .description + .as_deref(), + Some("10") + ); + assert_eq!( + before.object.description.as_deref(), + Some("3"), + "old snapshots stay immutable" + ); + assert_eq!(increment(&world, -12).unwrap().result, Value::Integer(-2)); + assert_eq!( + call( + &world, + "/playground/apple", + "counter", + "reset", + BTreeMap::new() + ) + .unwrap() + .result, + Value::Integer(0) + ); + assert_eq!(observe(&world, "/playground/apple", 1).object.name, "apple"); + assert_eq!( + collection(&world, "create", "New_counter-2") + .unwrap() + .result, + Value::Unit + ); + assert_eq!( + observe(&world, "/playground/New_counter-2", 1) + .object + .description + .as_deref(), + Some("0") + ); + collection(&world, "remove", "apple").unwrap(); + assert_eq!( + call( + &world, + "/playground/apple", + "counter", + "get", + BTreeMap::new() + ) + .unwrap_err() + .code, + ProtocolErrorCode::NotFound + ); + assert!( + !observe(&world, "/playground", 1) + .children + .unwrap() + .iter() + .any(|child| child.object.name == "apple") + ); + collection(&world, "create", "apple").unwrap(); + assert_eq!( + observe(&world, "/playground/apple", 1) + .object + .description + .as_deref(), + Some("0") + ); + for (path, expected) in documents { + assert_eq!( + call(&world, path, "document", "read", BTreeMap::new()) + .unwrap() + .result, + expected + ); + } +} + +#[test] +fn invalid_names_duplicates_missing_and_capacity_fail_without_mutation() { + let world = world(); + let before = observe(&world, "/", 3); + for name in [ + "", + ".", + "..", + "a/b", + "a b", + "a\nb", + "