diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index b3213f7f..635ca0b1 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -29,6 +29,21 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, StorageError> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } impl StateStore for LocalStore { @@ -59,4 +74,16 @@ impl StateHandle for ModuleStore { fn list_keys(&self, prefix: &str) -> Result, StorageError> { ModuleStore::list_keys(self, prefix) } + + fn contains(&self, key: &str) -> Result { + ModuleStore::contains(self, key) + } + + fn len(&self, key: &str) -> Result, StorageError> { + ModuleStore::len(self, key) + } + + fn count(&self, prefix: &str) -> Result { + ModuleStore::count(self, prefix) + } } diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index 32a17f09..e5abc7c2 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -21,4 +21,16 @@ impl nexum::host::local_store::Host for HostState { async fn list_keys(&mut self, prefix: String) -> Result, Fault> { self.store.list_keys(&prefix).map_err(Fault::from) } + + async fn contains(&mut self, key: String) -> Result { + self.store.contains(&key).map_err(Fault::from) + } + + async fn len(&mut self, key: String) -> Result, Fault> { + self.store.len(&key).map_err(Fault::from) + } + + async fn count(&mut self, prefix: String) -> Result { + self.store.count(&prefix).map_err(Fault::from) + } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index eec7c4ef..8b4008cd 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -105,6 +105,49 @@ impl ModuleStore { Ok(value) } + /// Whether `key` exists, without copying the value out. + pub fn contains(&self, key: &str) -> Result { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .is_some()) + } + + /// Value byte length for `key`, `Ok(None)` when absent. Reads the + /// entry's length in place; the value bytes are never copied out. + pub fn len(&self, key: &str) -> Result, StorageError> { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| v.value().len() as u64)) + } + + /// Number of module-visible keys starting with `prefix`. A bounded + /// B-tree range scan: no key strings are materialised. + pub fn count(&self, prefix: &str) -> Result { + let full_prefix = self.build_key(prefix); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let mut count = 0u64; + for entry in table + .range(full_prefix.as_slice()..) + .map_err(StorageError::Storage)? + { + let (k, _v) = entry.map_err(StorageError::Storage)?; + if !k.value().starts_with(&full_prefix) { + break; + } + count += 1; + } + Ok(count) + } + /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, /// value, overhead) and rejects an over-quota write untouched. The commit /// is fsync-durable. diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 1191d8d1..3e1fd159 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -66,6 +66,47 @@ fn list_keys_strips_namespace_prefix() { assert!(keys.iter().all(|k| k.starts_with("posted:"))); } +#[test] +fn contains_answers_without_the_value() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("k", b"v").unwrap(); + assert!(ms.contains("k").unwrap()); + assert!(!ms.contains("missing").unwrap()); + ms.delete("k").unwrap(); + assert!(!ms.contains("k").unwrap()); +} + +#[test] +fn len_reports_value_bytes_or_none() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("empty", b"").unwrap(); + ms.set("k", b"abcde").unwrap(); + assert_eq!(ms.len("empty").unwrap(), Some(0)); + assert_eq!(ms.len("k").unwrap(), Some(5)); + assert_eq!(ms.len("missing").unwrap(), None); +} + +#[test] +fn count_matches_list_keys_and_respects_namespaces() { + let (_dir, store) = fresh(); + let a = store.module("a").unwrap(); + let b = store.module("b").unwrap(); + a.set("posted:1", b"x").unwrap(); + a.set("posted:2", b"y").unwrap(); + a.set("other", b"z").unwrap(); + b.set("posted:9", b"w").unwrap(); + assert_eq!(a.count("posted:").unwrap(), 2); + assert_eq!(a.count("").unwrap(), 3); + assert_eq!(a.count("nope:").unwrap(), 0); + assert_eq!(b.count("posted:").unwrap(), 1); + assert_eq!( + a.count("posted:").unwrap(), + a.list_keys("posted:").unwrap().len() as u64 + ); +} + #[test] fn rejects_empty_namespace() { let (_dir, store) = fresh(); diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..4f9bd78c 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -111,6 +111,16 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: MockLocalStore's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } } impl LoggingHost for MockHost { @@ -284,6 +294,23 @@ impl LocalStoreHost for MockLocalStore { keys.sort(); Ok(keys) } + fn contains(&self, key: &str) -> Result { + self.check_injected_error(key)?; + Ok(self.rows.borrow().contains_key(key)) + } + fn len(&self, key: &str) -> Result, Fault> { + self.check_injected_error(key)?; + Ok(self.rows.borrow().get(key).map(|v| v.len() as u64)) + } + fn count(&self, prefix: &str) -> Result { + self.check_injected_error(prefix)?; + Ok(self + .rows + .borrow() + .keys() + .filter(|k| k.starts_with(prefix)) + .count() as u64) + } } // ---------------------------------------------------------------- logging @@ -630,6 +657,28 @@ mod tests { assert_eq!(keys, vec!["watch:a:1", "watch:a:2"]); } + #[test] + fn local_store_metadata_queries() { + let store = MockLocalStore::default(); + store.set("watch:a", b"abc").unwrap(); + store.set("watch:b", b"").unwrap(); + store.set("posted:1", b"x").unwrap(); + + assert!(store.contains("watch:a").unwrap()); + assert!(!store.contains("missing").unwrap()); + assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3)); + assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0)); + assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None); + assert_eq!(store.count("watch:").unwrap(), 2); + assert_eq!(store.count("").unwrap(), 3); + + // And respect fault injection. + store.fail_on("bad:", Fault::Internal("injected".into())); + assert!(store.contains("bad:k").is_err()); + assert!(LocalStoreHost::len(&store, "bad:k").is_err()); + assert!(store.count("bad:").is_err()); + } + #[test] fn logging_captures_lines_and_filters_by_level() { let log = MockLogging::default(); diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 6e0e71d5..7d944896 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -192,6 +192,21 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, Fault> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } /// `nexum:host/logging` - structured runtime logs. @@ -282,6 +297,45 @@ mod tests { assert_eq!(boxed.label(), "timeout"); } + #[test] + fn local_store_metadata_defaults_derive_from_required_methods() { + use super::LocalStoreHost; + + /// Two fixed rows; only the four required methods are written. + struct TwoRows; + impl LocalStoreHost for TwoRows { + fn get(&self, key: &str) -> Result>, Fault> { + Ok(match key { + "a" => Some(b"abc".to_vec()), + "b" => Some(Vec::new()), + _ => None, + }) + } + fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), Fault> { + Ok(()) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + Ok(["a", "b"] + .iter() + .filter(|k| k.starts_with(prefix)) + .map(|k| (*k).to_owned()) + .collect()) + } + } + + assert!(TwoRows.contains("a").unwrap()); + assert!(!TwoRows.contains("missing").unwrap()); + assert_eq!(TwoRows.len("a").unwrap(), Some(3)); + assert_eq!(TwoRows.len("b").unwrap(), Some(0)); + assert_eq!(TwoRows.len("missing").unwrap(), None); + assert_eq!(TwoRows.count("").unwrap(), 2); + assert_eq!(TwoRows.count("a").unwrap(), 1); + assert_eq!(TwoRows.count("z").unwrap(), 0); + } + #[test] fn chain_error_recovers_embedded_fault() { let fault = ChainError::Fault(Fault::Timeout); diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 3e33c68c..43cdbbcf 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -88,6 +88,18 @@ macro_rules! bind_host_via_wit_bindgen { { nexum::host::local_store::list_keys(prefix).map_err(convert_fault) } + fn contains(&self, key: &str) -> ::core::result::Result { + nexum::host::local_store::contains(key).map_err(convert_fault) + } + fn len( + &self, + key: &str, + ) -> ::core::result::Result<::core::option::Option, $crate::host::Fault> { + nexum::host::local_store::len(key).map_err(convert_fault) + } + fn count(&self, prefix: &str) -> ::core::result::Result { + nexum::host::local_store::count(prefix).map_err(convert_fault) + } } impl $crate::host::LoggingHost for WitBindgenHost { diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index e1289cd0..af7c38a5 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -77,6 +77,16 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: MockLocalStore's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } } impl CowApiHost for MockHost { diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 6c5a22b3..31782bd8 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -15,4 +15,15 @@ interface local-store { /// List all keys matching a prefix. Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + + /// Whether the key exists, without transferring the value. + contains: func(key: string) -> result; + + /// Value byte length, none if the key is absent, without + /// transferring the value. On some backends this may be a scan. + len: func(key: string) -> result, fault>; + + /// Number of keys matching a prefix, without materialising the key + /// list. On some backends this may be a scan. + count: func(prefix: string) -> result; }