From 32998dfc159b10e5879e04a0e42a97bf4233dd40 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:05:56 +0530 Subject: [PATCH 01/21] feat: allow spin as a supported config store adapter --- crates/edgezero-core/src/manifest.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 571a496..8d6ea49 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -54,7 +54,7 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; +const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly", "spin"]; #[derive(Debug, Deserialize, Validate)] pub struct Manifest { @@ -1426,21 +1426,15 @@ name = "APP_CONFIG" } #[test] - fn config_store_spin_adapter_key_fails_validation() { + fn config_store_spin_adapter_key_passes_validation() { let src = r#" [stores.config.adapters.spin] name = "SPIN_CONFIG" "#; let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); - assert!( - result.is_err(), - "spin config store adapter key should fail validation because it is not implemented yet" - ); - let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("spin"), - "error should name the unknown adapter: {err_msg}" + manifest.validate().is_ok(), + "spin config store adapter key should pass validation" ); } From 1022cd7eac010297862b48e4b2da635144a2034b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:08:26 +0530 Subject: [PATCH 02/21] docs: update adapters field comment to include spin --- crates/edgezero-core/src/manifest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 8d6ea49..c6f7e27 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -404,7 +404,7 @@ pub struct ManifestConfigStoreConfig { #[validate(length(min = 1))] pub name: Option, /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, or `fastly`). + /// (`axum`, `cloudflare`, `fastly`, or `spin`). #[serde(default)] #[validate(nested)] #[validate(custom(function = "validate_config_store_adapter_keys"))] From 5bb805183fb222fcd7055bf197d9f02c45a260e3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:13:30 +0530 Subject: [PATCH 03/21] feat: add SpinConfigStore backed by spin_sdk::variables --- .../edgezero-adapter-spin/src/config_store.rs | 81 +++++++++++++++++++ crates/edgezero-adapter-spin/src/lib.rs | 4 + 2 files changed, 85 insertions(+) create mode 100644 crates/edgezero-adapter-spin/src/config_store.rs diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs new file mode 100644 index 0000000..c31a20b --- /dev/null +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -0,0 +1,81 @@ +//! Spin adapter config store: wraps `spin_sdk::variables`. + +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; + +/// Config store backed by Spin component variables. +pub struct SpinConfigStore { + inner: SpinConfigInner, +} + +enum SpinConfigInner { + #[cfg(target_arch = "wasm32")] + Spin, + #[cfg(test)] + InMemory(std::collections::HashMap), + /// Placeholder variant for non-wasm32, non-test builds. + /// + /// This variant is never constructed; it exists solely to keep the enum + /// inhabited so that `match` arms compile without `unreachable!()` noise. + #[cfg(not(any(target_arch = "wasm32", test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Create a new `SpinConfigStore` using the Spin variables API. + #[cfg(target_arch = "wasm32")] + pub fn new() -> Self { + Self { + inner: SpinConfigInner::Spin, + } + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { + inner: SpinConfigInner::InMemory(entries.into_iter().collect()), + } + } +} + +#[cfg(target_arch = "wasm32")] +impl Default for SpinConfigStore { + fn default() -> Self { + Self::new() + } +} + +impl ConfigStore for SpinConfigStore { + #[allow(unused_variables)] + fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(target_arch = "wasm32")] + SpinConfigInner::Spin => { + use spin_sdk::variables; + match variables::get(key) { + Ok(value) => Ok(Some(value)), + Err(variables::Error::Undefined(_)) => Ok(None), + Err(variables::Error::InvalidName(msg)) => { + Err(ConfigStoreError::invalid_key(msg)) + } + Err(e) => Err(ConfigStoreError::unavailable(e.to_string())), + } + } + #[cfg(test)] + SpinConfigInner::InMemory(data) => Ok(data.get(key).cloned()), + #[cfg(not(any(target_arch = "wasm32", test)))] + SpinConfigInner::_Uninhabited(never) => match *never {}, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + edgezero_core::config_store_contract_tests!(spin_config_store_contract, { + SpinConfigStore::from_entries([ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ]) + }); +} diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 9722fb5..0c86064 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -11,6 +11,8 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; +#[cfg(feature = "spin")] +pub mod config_store; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -19,6 +21,8 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; +#[cfg(feature = "spin")] +pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. /// From d64e95dc65c20d23669a33071ae6a881ad9a972d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:18:59 +0530 Subject: [PATCH 04/21] fix: rename SpinConfigBackend and remove feature gate on config_store module --- .../edgezero-adapter-spin/src/config_store.rs | 19 ++++++++----------- crates/edgezero-adapter-spin/src/lib.rs | 2 -- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index c31a20b..8547e65 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -4,18 +4,15 @@ use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; /// Config store backed by Spin component variables. pub struct SpinConfigStore { - inner: SpinConfigInner, + inner: SpinConfigBackend, } -enum SpinConfigInner { +enum SpinConfigBackend { #[cfg(target_arch = "wasm32")] Spin, #[cfg(test)] InMemory(std::collections::HashMap), - /// Placeholder variant for non-wasm32, non-test builds. - /// - /// This variant is never constructed; it exists solely to keep the enum - /// inhabited so that `match` arms compile without `unreachable!()` noise. + /// Never constructed; keeps the enum inhabited in non-wasm32, non-test builds. #[cfg(not(any(target_arch = "wasm32", test)))] _Uninhabited(std::convert::Infallible), } @@ -25,14 +22,14 @@ impl SpinConfigStore { #[cfg(target_arch = "wasm32")] pub fn new() -> Self { Self { - inner: SpinConfigInner::Spin, + inner: SpinConfigBackend::Spin, } } #[cfg(test)] fn from_entries(entries: impl IntoIterator) -> Self { Self { - inner: SpinConfigInner::InMemory(entries.into_iter().collect()), + inner: SpinConfigBackend::InMemory(entries.into_iter().collect()), } } } @@ -49,7 +46,7 @@ impl ConfigStore for SpinConfigStore { fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(target_arch = "wasm32")] - SpinConfigInner::Spin => { + SpinConfigBackend::Spin => { use spin_sdk::variables; match variables::get(key) { Ok(value) => Ok(Some(value)), @@ -61,9 +58,9 @@ impl ConfigStore for SpinConfigStore { } } #[cfg(test)] - SpinConfigInner::InMemory(data) => Ok(data.get(key).cloned()), + SpinConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), #[cfg(not(any(target_arch = "wasm32", test)))] - SpinConfigInner::_Uninhabited(never) => match *never {}, + SpinConfigBackend::_Uninhabited(never) => match *never {}, } } } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 0c86064..055fe47 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -11,7 +11,6 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; -#[cfg(feature = "spin")] pub mod config_store; pub use context::SpinRequestContext; @@ -21,7 +20,6 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; -#[cfg(feature = "spin")] pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. From 53dfa1c1c02fc4775688c77a3576e51de33b3650 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:22:10 +0530 Subject: [PATCH 05/21] fix: fmt ordering in lib.rs and add SpinConfigStore to ConfigStore trait docs --- crates/edgezero-adapter-spin/src/lib.rs | 4 ++-- crates/edgezero-core/src/config_store.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 055fe47..9b156e9 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -3,6 +3,7 @@ #[cfg(feature = "cli")] pub mod cli; +pub mod config_store; mod context; mod decompress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -11,8 +12,8 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; -pub mod config_store; +pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; @@ -20,7 +21,6 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; -pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. /// diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 5112449..696dfc4 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -61,6 +61,7 @@ impl ConfigStoreError { /// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store /// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings +/// - `SpinConfigStore` (spin adapter) — Spin component variables pub trait ConfigStore: Send + Sync { /// Retrieve a config value by key. Returns `None` if the key does not exist. fn get(&self, key: &str) -> Result, ConfigStoreError>; From 917ff578072d6b75f022b699987978e5f5764239 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:24:08 +0530 Subject: [PATCH 06/21] feat: add SpinSecretStore backed by spin_sdk::variables --- crates/edgezero-adapter-spin/src/lib.rs | 4 ++ .../edgezero-adapter-spin/src/secret_store.rs | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 crates/edgezero-adapter-spin/src/secret_store.rs diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 9b156e9..69a89b4 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -16,6 +16,10 @@ mod response; pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod secret_store; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use secret_store::SpinSecretStore; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use request::{dispatch, into_core_request}; diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs new file mode 100644 index 0000000..2cbf691 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -0,0 +1,55 @@ +//! Spin adapter secret store: wraps `spin_sdk::variables`. +//! +//! Spin's variable namespace is flat — there is no concept of named stores. +//! The `store_name` parameter is intentionally ignored; provision secrets as +//! application variables in `spin.toml`. + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Spin component variables. +/// +/// `store_name` is ignored — Spin's variable namespace is flat. +/// Provision secrets as application variables in `spin.toml`. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub struct SpinSecretStore; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl SpinSecretStore { + pub fn new() -> Self { + Self + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl Default for SpinSecretStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SecretStore for SpinSecretStore { + async fn get_bytes( + &self, + _store_name: &str, + key: &str, + ) -> Result, SecretError> { + use spin_sdk::variables; + match variables::get(key) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(variables::Error::Undefined(_)) => Ok(None), + Err(e) => Err(SecretError::Internal(anyhow::anyhow!( + "secret lookup failed: {e}" + ))), + } + } +} + +// TODO: integration tests require the Spin runtime. +// Test SpinSecretStore as part of a Spin E2E test suite. From 8a4e2270f69b47c20e568a3f14759c284154fcf9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:35:08 +0530 Subject: [PATCH 07/21] feat: add SpinKvStore backed by spin_sdk::key_value --- .../src/key_value_store.rs | 140 ++++++++++++++++++ crates/edgezero-adapter-spin/src/lib.rs | 8 +- 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 crates/edgezero-adapter-spin/src/key_value_store.rs diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs new file mode 100644 index 0000000..19c4761 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -0,0 +1,140 @@ +//! Spin KV store adapter. +//! +//! Wraps `spin_sdk::key_value::Store` to implement the +//! `edgezero_core::key_value_store::KvStore` trait. +//! +//! # Limitations +//! +//! - **TTL**: The Spin KV API has no TTL support. Calls to +//! `put_bytes_with_ttl` store the value without expiry and emit a +//! `log::warn!`. +//! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys +//! with no prefix or cursor support. Prefix filtering and pagination are +//! performed in-process after fetching all keys. +//! +//! # Note +//! +//! This module is only compiled when the `spin` feature is enabled and the +//! target is `wasm32`. + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use std::time::Duration; + +/// KV store backed by the Spin KV API. +/// +/// Wraps a `spin_sdk::key_value::Store` handle obtained via +/// `Store::open(label)`. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub struct SpinKvStore { + store: spin_sdk::key_value::Store, +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl SpinKvStore { + /// Open a Spin KV store by label. + /// + /// The `label` must match a `key_value_stores` entry in `spin.toml`. + /// Returns `KvError::Internal` if the store cannot be opened. + pub fn open(label: &str) -> Result { + let store = spin_sdk::key_value::Store::open(label) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))?; + Ok(Self { store }) + } + + /// Open the default Spin KV store (label `"default"`). + pub fn open_default() -> Result { + Self::open("default") + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl KvStore for SpinKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.store + .get(key) + .map(|opt| opt.map(Bytes::from)) + .map_err(|e| KvError::Internal(anyhow::anyhow!("get failed: {e}"))) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store + .set(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + log::warn!( + "SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry" + ); + self.store + .set(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) + } + + async fn exists(&self, key: &str) -> Result { + self.store + .exists(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("exists failed: {e}"))) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let mut keys: Vec = self + .store + .get_keys() + .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))? + .into_iter() + .filter(|k| k.starts_with(prefix)) + .collect(); + + keys.sort(); + + // Advance past all keys <= last_key (the cursor). + let start = if let Some(last_key) = cursor { + keys.iter() + .position(|k| k.as_str() > last_key) + .unwrap_or(keys.len()) + } else { + 0 + }; + + let remaining = &keys[start..]; + let page_keys: Vec = remaining.iter().take(limit).cloned().collect(); + let has_more = remaining.len() > limit; + let next_cursor = if has_more { + page_keys.last().cloned() + } else { + None + }; + + Ok(KvPage { + keys: page_keys, + cursor: next_cursor, + }) + } +} + +// TODO: integration tests require the Spin runtime. +// Test `SpinKvStore` as part of a Spin E2E test suite. diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 69a89b4..1e50734 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -16,15 +16,19 @@ mod response; pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -mod secret_store; +mod key_value_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use secret_store::SpinSecretStore; +pub use key_value_store::SpinKvStore; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod secret_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use secret_store::SpinSecretStore; /// Initialize the logger for Spin. /// From ac8facb255736217ea102aac1800b07d26cb03d4 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:39:10 +0530 Subject: [PATCH 08/21] chore: remove stale 'not yet implemented' note from run_app doc comment --- crates/edgezero-adapter-spin/src/lib.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 1e50734..6c3064f 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -78,11 +78,6 @@ impl AppExt for edgezero_core::app::App { /// edgezero_adapter_spin::run_app::(req).await /// } /// ``` -/// -/// **Note:** Config store, KV store, and secret store support are not yet -/// implemented for the Spin adapter. The `[stores.config]`, `[stores.kv]`, -/// and `[stores.secrets]` manifest sections are intentionally rejected for -/// the `spin` adapter. See the manifest validation error for details. #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub async fn run_app( req: spin_sdk::http::IncomingRequest, From a0ec8e64ed1b4b0c69b8f3b0ada249e8413011bf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:49:32 +0530 Subject: [PATCH 09/21] ci: add spin-adapter-tests job --- .github/workflows/test.yml | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c87283..015a0f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -172,3 +172,44 @@ jobs: - name: Check Fastly wasm target run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 + + spin-adapter-tests: + name: spin adapter tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-spin-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-spin- + + - name: Retrieve Rust version + id: rust-version-spin + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-spin.outputs.rust-version }} + + - name: Add wasm32-wasip1 target + run: rustup target add wasm32-wasip1 + + - name: Fetch dependencies (locked) + run: cargo fetch --locked + + - name: Run Spin adapter native tests + run: cargo test -p edgezero-adapter-spin --features spin + + - name: Check Spin wasm32 compilation + run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin From cf57f583c4bffb6efd81c59d10beb16cce733419 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:54:46 +0530 Subject: [PATCH 10/21] fix: handle InvalidName in SpinSecretStore and restore allow(unused_variables) --- crates/edgezero-adapter-spin/src/config_store.rs | 1 + crates/edgezero-adapter-spin/src/secret_store.rs | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 8547e65..bfa8d49 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -42,6 +42,7 @@ impl Default for SpinConfigStore { } impl ConfigStore for SpinConfigStore { + // `key` is unused in the _Uninhabited arm on native non-test builds #[allow(unused_variables)] fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index 2cbf691..8d971af 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -35,15 +35,12 @@ impl Default for SpinSecretStore { #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for SpinSecretStore { - async fn get_bytes( - &self, - _store_name: &str, - key: &str, - ) -> Result, SecretError> { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; match variables::get(key) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), Err(variables::Error::Undefined(_)) => Ok(None), + Err(variables::Error::InvalidName(msg)) => Err(SecretError::Validation(msg)), Err(e) => Err(SecretError::Internal(anyhow::anyhow!( "secret lookup failed: {e}" ))), From ec7dc149f0d990519b37797b4ce4f12475430679 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 21:01:23 +0530 Subject: [PATCH 11/21] feat: add configurable max_list_keys cap to SpinKvStore --- .../src/key_value_store.rs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index 19c4761..924a7e3 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -10,7 +10,12 @@ //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys //! with no prefix or cursor support. Prefix filtering and pagination are -//! performed in-process after fetching all keys. +//! performed in-process after fetching all keys. A configurable cap +//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) guards against +//! unbounded allocations; when the total key count exceeds it the list is +//! silently truncated, a `log::warn!` is emitted, and a partial page is +//! returned so the caller can resume via cursor (matching the Axum adapter +//! behaviour for its scan-batch limit). //! //! # Note //! @@ -26,6 +31,12 @@ use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; +/// Maximum number of keys fetched from the Spin KV host before +/// `list_keys_page` returns `KvError::Validation`. Overridable via +/// [`SpinKvStore::with_max_list_keys`]. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub const DEFAULT_MAX_LIST_KEYS: usize = 10_000; + /// KV store backed by the Spin KV API. /// /// Wraps a `spin_sdk::key_value::Store` handle obtained via @@ -33,6 +44,7 @@ use std::time::Duration; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinKvStore { store: spin_sdk::key_value::Store, + max_list_keys: usize, } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -44,13 +56,26 @@ impl SpinKvStore { pub fn open(label: &str) -> Result { let store = spin_sdk::key_value::Store::open(label) .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))?; - Ok(Self { store }) + Ok(Self { + store, + max_list_keys: DEFAULT_MAX_LIST_KEYS, + }) } /// Open the default Spin KV store (label `"default"`). pub fn open_default() -> Result { Self::open("default") } + + /// Override the maximum number of keys fetched during `list_keys_page`. + /// + /// When the Spin KV store contains more than `limit` keys, + /// `list_keys_page` returns `KvError::Validation` rather than + /// allocating an unbounded `Vec`. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. + pub fn with_max_list_keys(mut self, limit: usize) -> Self { + self.max_list_keys = limit; + self + } } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -75,9 +100,7 @@ impl KvStore for SpinKvStore { value: Bytes, _ttl: Duration, ) -> Result<(), KvError> { - log::warn!( - "SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry" - ); + log::warn!("SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry"); self.store .set(key, value.as_ref()) .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) @@ -101,11 +124,24 @@ impl KvStore for SpinKvStore { cursor: Option<&str>, limit: usize, ) -> Result { - let mut keys: Vec = self + let all_keys = self .store .get_keys() - .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))? + .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; + + if all_keys.len() > self.max_list_keys { + log::warn!( + "SpinKvStore: fetched {} keys, exceeding max_list_keys={}; \ + processing the first {} keys only. Use with_max_list_keys to scan the full store.", + all_keys.len(), + self.max_list_keys, + self.max_list_keys, + ); + } + + let mut keys: Vec = all_keys .into_iter() + .take(self.max_list_keys) .filter(|k| k.starts_with(prefix)) .collect(); From a905d5af3aaa46baed75ffc06990a9a4385a28cc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:14:58 +0530 Subject: [PATCH 12/21] feat: wire store handles into Spin dispatch and add contract tests - dispatch() in request.rs now injects ConfigStoreHandle, KvHandle, and SecretHandle into request extensions on every request - SpinSecretStore normalises the lookup key to lowercase so conventional uppercase names (e.g. SMOKE_SECRET) resolve to the correct Spin variable - contract.rs gains store injection smoke tests (config, kv, secret) and wasm32 compile-time trait checks for SpinKvStore and SpinSecretStore --- crates/edgezero-adapter-spin/src/request.rs | 39 +++- .../edgezero-adapter-spin/src/secret_store.rs | 6 +- .../edgezero-adapter-spin/tests/contract.rs | 202 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 736bb2c..c8a305f 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -1,11 +1,19 @@ +use std::sync::Arc; + +use crate::config_store::SpinConfigStore; use crate::context::SpinRequestContext; +use crate::key_value_store::SpinKvStore; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; +use crate::secret_store::SpinSecretStore; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request, Uri}; +use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use spin_sdk::http::IncomingRequest; /// Convert a Spin `IncomingRequest` into an EdgeZero core `Request`. @@ -84,8 +92,37 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { - let core_request = into_core_request(req).await?; + let mut core_request = into_core_request(req).await?; + + core_request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(SpinConfigStore::new()))); + + match SpinKvStore::open_default() { + Ok(store) => { + core_request + .extensions_mut() + .insert(KvHandle::new(Arc::new(store))); + } + Err(e) => { + log::warn!( + "SpinKvStore: could not open default KV store (label \"default\"); \ + KV operations will be unavailable: {e}" + ); + } + } + + core_request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(SpinSecretStore::new()))); + let response = app.router().oneshot(core_request).await; Ok(from_core_response(response).await?) } diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index 8d971af..b252b59 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -37,7 +37,11 @@ impl Default for SpinSecretStore { impl SecretStore for SpinSecretStore { async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; - match variables::get(key) { + // Spin variable names are always lowercase. Normalise the key so that + // conventional uppercase secret names (e.g. "STRIPE_KEY") work without + // callers needing to know the Spin naming convention. + let lower = key.to_ascii_lowercase(); + match variables::get(&lower) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), Err(variables::Error::Undefined(_)) => Ok(None), Err(variables::Error::InvalidName(msg)) => Err(SecretError::Validation(msg)), diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 2df70de..fb27673 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -2,12 +2,70 @@ use bytes::Bytes; use edgezero_adapter_spin::SpinRequestContext; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, response_builder, Response, StatusCode}; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; use edgezero_core::router::RouterService; +use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; use futures::executor::block_on; use futures::stream; +use std::sync::Arc; + +struct FixedConfigStore(&'static str); + +impl ConfigStore for FixedConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_string())) + } +} + +struct StubKvStore; + +#[async_trait::async_trait(?Send)] +impl KvStore for StubKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + Ok(()) + } + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + async fn exists(&self, _key: &str) -> Result { + Ok(false) + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: vec![], + cursor: None, + }) + } +} + +struct StubSecretStore; + +#[async_trait::async_trait(?Send)] +impl SecretStore for StubSecretStore { + async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { + Ok(None) + } +} fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { @@ -41,10 +99,51 @@ fn build_test_app() -> App { Ok(response) } + async fn config_value(ctx: RequestContext) -> Result { + let value = ctx + .config_store() + .and_then(|store| store.get("greeting").ok().flatten()) + .unwrap_or_else(|| "missing".to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + + async fn kv_presence(ctx: RequestContext) -> Result { + let body = Body::text(if ctx.kv_handle().is_some() { + "yes" + } else { + "no" + }); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + + async fn secret_presence(ctx: RequestContext) -> Result { + let body = Body::text(if ctx.secret_handle().is_some() { + "yes" + } else { + "no" + }); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + let router = RouterService::builder() .get("/uri", capture_uri) .post("/mirror", mirror_body) .get("/stream", stream_response) + .get("/config", config_value) + .get("/has-kv", kv_presence) + .get("/has-secret", secret_presence) .build(); App::new(router) @@ -127,6 +226,95 @@ fn router_dispatches_streaming_route() { assert_eq!(collected, b"chunk-1chunk-2"); } +// --------------------------------------------------------------------------- +// Store injection smoke tests (host-side, no Spin runtime required) +// --------------------------------------------------------------------------- + +#[test] +fn config_store_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/config") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore( + "hello-spin", + )))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"hello-spin"); +} + +#[test] +fn kv_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/has-kv") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(KvHandle::new(Arc::new(StubKvStore))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"yes"); +} + +#[test] +fn secret_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/has-secret") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(StubSecretStore))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"yes"); +} + +#[test] +fn missing_store_handles_return_absent_values_in_handler() { + let app = build_test_app(); + + let config_req = request_builder() + .method("GET") + .uri("http://example.com/config") + .body(Body::empty()) + .expect("request"); + let config_response = block_on(app.router().oneshot(config_req)); + assert_eq!(config_response.body().as_bytes(), b"missing"); + + let kv_req = request_builder() + .method("GET") + .uri("http://example.com/has-kv") + .body(Body::empty()) + .expect("request"); + let kv_response = block_on(app.router().oneshot(kv_req)); + assert_eq!(kv_response.body().as_bytes(), b"no"); + + let secret_req = request_builder() + .method("GET") + .uri("http://example.com/has-secret") + .body(Body::empty()) + .expect("request"); + let secret_response = block_on(app.router().oneshot(secret_req)); + assert_eq!(secret_response.body().as_bytes(), b"no"); +} + // --------------------------------------------------------------------------- // Tests that require `spin_sdk` types (wasm32 + spin feature only) // @@ -192,3 +380,17 @@ mod wasm { }); } } + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod store_trait_compile_checks { + use edgezero_adapter_spin::{SpinKvStore, SpinSecretStore}; + use edgezero_core::key_value_store::KvStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_kv_impl() {} + fn _assert_secret_impl() {} + fn _check() { + _assert_kv_impl::(); + _assert_secret_impl::(); + } +} From 11aa4626d113392b96e88732aca1ef61dad13468 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:15:17 +0530 Subject: [PATCH 13/21] feat: add Spin adapter support to smoke test scripts - spin.toml gains key_value_stores = ["default"] binding and variables declarations for greeting and smoke_secret - edgezero.toml adds "spin" to adapters for config, kv, and secrets routes - smoke_test_kv/config/secrets.sh each gain a spin case that builds the WASM binary and starts spin up --listen 127.0.0.1:3000; the config script skips dotted-key checks (Spin variable names cannot contain dots); the secrets script passes SPIN_VARIABLE_SMOKE_SECRET at startup --- .../crates/app-demo-adapter-spin/spin.toml | 16 +++++++++ examples/app-demo/edgezero.toml | 12 +++---- scripts/smoke_test_config.sh | 34 +++++++++++++++---- scripts/smoke_test_kv.sh | 15 +++++++- scripts/smoke_test_secrets.sh | 24 ++++++++++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml index 3133f8c..d5c7595 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml @@ -4,6 +4,15 @@ spin_manifest_version = 2 name = "app-demo-adapter-spin" version = "0.1.0" +# Application-level variable declarations. +# Spin variable names are lowercase; set overrides at runtime via +# SPIN_VARIABLE_=value or `spin up --env KEY=value`. +[variables] +greeting = { default = "hello from config store" } +# smoke_secret has an empty default so the server starts without a value set. +# Pass SPIN_VARIABLE_SMOKE_SECRET= when running smoke_test_secrets.sh. +smoke_secret = { default = "" } + # Component name is shortened for brevity; scaffolded projects use the full # adapter crate name (e.g. "{{proj_spin}}") via the template. [[trigger.http]] @@ -13,6 +22,13 @@ component = "app-demo" [component.app-demo] source = "../../target/wasm32-wasip1/release/app_demo_adapter_spin.wasm" allowed_outbound_hosts = ["https://*:*"] +# KV store bound to the "default" label; SpinKvStore opens this label by default. +key_value_stores = ["default"] + +[component.app-demo.variables] +greeting = "{{ greeting }}" +smoke_secret = "{{ smoke_secret }}" + [component.app-demo.build] command = "cargo build --target wasm32-wasip1 --release" watch = ["src/**/*.rs", "Cargo.toml"] diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 497df6d..c6be1f4 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -57,7 +57,7 @@ id = "config_get" path = "/config/{name}" methods = ["GET"] handler = "app_demo_core::handlers::config_get" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] # -- KV demo routes -------------------------------------------------------- @@ -66,7 +66,7 @@ id = "kv_counter" path = "/kv/counter" methods = ["POST"] handler = "app_demo_core::handlers::kv_counter" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Increment and return a visit counter stored in KV" [[triggers.http]] @@ -74,7 +74,7 @@ id = "kv_note_put" path = "/kv/notes/{id}" methods = ["POST"] handler = "app_demo_core::handlers::kv_note_put" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Store a note by id" [[triggers.http]] @@ -82,7 +82,7 @@ id = "kv_note_get" path = "/kv/notes/{id}" methods = ["GET"] handler = "app_demo_core::handlers::kv_note_get" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Read a note by id" [[triggers.http]] @@ -90,7 +90,7 @@ id = "kv_note_delete" path = "/kv/notes/{id}" methods = ["DELETE"] handler = "app_demo_core::handlers::kv_note_delete" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Delete a note by id" # -- Secrets demo route -------------------------------------------------------- @@ -100,7 +100,7 @@ id = "secrets_echo" path = "/secrets/echo" methods = ["GET"] handler = "app_demo_core::handlers::secrets_echo" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Echo an allowlisted smoke-test secret value (smoke-test only — do not use in production)" # -- Stores ---------------------------------------------------------------- diff --git a/scripts/smoke_test_config.sh b/scripts/smoke_test_config.sh index 5de4c1a..9e67c4d 100755 --- a/scripts/smoke_test_config.sh +++ b/scripts/smoke_test_config.sh @@ -9,6 +9,10 @@ set -euo pipefail # ./scripts/smoke_test_config.sh axum # ./scripts/smoke_test_config.sh fastly # ./scripts/smoke_test_config.sh cloudflare +# ./scripts/smoke_test_config.sh spin +# +# Note (spin): Spin variable names may not contain dots. Keys with dots +# (feature.new_checkout, service.timeout_ms) are skipped for the spin adapter. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -57,9 +61,21 @@ case "$ADAPTER" in (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac @@ -115,14 +131,18 @@ check "GET /config/greeting returns 200" "200" "$STATUS" BODY=$(curl -s "$BASE/config/greeting") check "greeting value" "hello from config store" "$BODY" -STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/feature.new_checkout") -check "GET /config/feature.new_checkout returns 200" "200" "$STATUS" +# Spin variable names cannot contain dots; these keys are only tested on +# adapters whose config stores support arbitrary key names. +if [ "$ADAPTER" != "spin" ]; then + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/feature.new_checkout") + check "GET /config/feature.new_checkout returns 200" "200" "$STATUS" -BODY=$(curl -s "$BASE/config/feature.new_checkout") -check "feature.new_checkout value" "false" "$BODY" + BODY=$(curl -s "$BASE/config/feature.new_checkout") + check "feature.new_checkout value" "false" "$BODY" -BODY=$(curl -s "$BASE/config/service.timeout_ms") -check "service.timeout_ms value" "1500" "$BODY" + BODY=$(curl -s "$BASE/config/service.timeout_ms") + check "service.timeout_ms value" "1500" "$BODY" +fi section "Config: missing key returns 404" STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/does.not.exist") diff --git a/scripts/smoke_test_kv.sh b/scripts/smoke_test_kv.sh index f2d0c1a..b11c1da 100755 --- a/scripts/smoke_test_kv.sh +++ b/scripts/smoke_test_kv.sh @@ -9,6 +9,7 @@ set -euo pipefail # ./scripts/smoke_test_kv.sh axum # ./scripts/smoke_test_kv.sh fastly # ./scripts/smoke_test_kv.sh cloudflare +# ./scripts/smoke_test_kv.sh spin ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -58,9 +59,21 @@ case "$ADAPTER" in (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac diff --git a/scripts/smoke_test_secrets.sh b/scripts/smoke_test_secrets.sh index 764c1a3..2ad8998 100755 --- a/scripts/smoke_test_secrets.sh +++ b/scripts/smoke_test_secrets.sh @@ -9,6 +9,12 @@ set -euo pipefail # ./scripts/smoke_test_secrets.sh axum # ./scripts/smoke_test_secrets.sh fastly # ./scripts/smoke_test_secrets.sh cloudflare +# ./scripts/smoke_test_secrets.sh spin +# +# Note (spin): Spin variable names are lowercase. SpinSecretStore normalises +# the key to lowercase before lookup, so "SMOKE_SECRET" maps to the Spin +# variable "smoke_secret". The secret value is passed at startup via +# SPIN_VARIABLE_SMOKE_SECRET. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -108,9 +114,25 @@ start_server() { (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + # SpinSecretStore normalises the key to lowercase, so SMOKE_SECRET maps to + # the Spin variable smoke_secret. Pass the value via SPIN_VARIABLE_SMOKE_SECRET. + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && \ + SPIN_VARIABLE_SMOKE_SECRET="$SMOKE_SECRET_VALUE" \ + spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac From 58c7b6ee121aa72fb424da8c5d4e587b288c07d5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:15:29 +0530 Subject: [PATCH 14/21] chore: ignore .spin/ runtime directory spin up creates .spin/ (SQLite KV database and component logs) in the adapter directory during local development, mirroring .wrangler/ for CF. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 48a5ede..0389097 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ bin/ pkg/ target/ .wrangler/ +.spin/ .edgezero/ # env From b5129da04ce5cecca86f445495022739bddf0f0d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 25 Apr 2026 18:12:37 +0530 Subject: [PATCH 15/21] - Return KvError::Validation when key count exceeds max_list_keys instead of silently truncating; callers now get an explicit signal rather than incomplete pagination results - Correct DEFAULT_MAX_LIST_KEYS, with_max_list_keys, and module-level docs to accurately describe error-return behaviour (not truncation, not "unbounded allocation" guarding) - Add log::debug in SpinSecretStore::get_bytes when store_name is non-empty so callers learn the flat-namespace constraint at runtime - Add comment in config_store contract tests explaining the InMemory backend accepts dotted/uppercase keys that the real Spin backend would reject via InvalidName - Add comment in lib.rs explaining why SpinConfigStore has different feature gating than SpinKvStore and SpinSecretStore --- .../edgezero-adapter-spin/src/config_store.rs | 5 +++ .../src/key_value_store.rs | 33 +++++++++---------- crates/edgezero-adapter-spin/src/lib.rs | 5 +++ .../edgezero-adapter-spin/src/secret_store.rs | 10 +++++- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index bfa8d49..84089f5 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -70,6 +70,11 @@ impl ConfigStore for SpinConfigStore { mod tests { use super::*; + // These contract tests exercise the InMemory backend (not the real Spin + // variables API). Dotted keys such as "contract.key.a" are valid here but + // would trigger `InvalidName` on the real Spin backend, which requires + // lowercase variable names without dots. Real-backend behaviour is + // verified by the smoke tests in scripts/smoke_test_config.sh. edgezero_core::config_store_contract_tests!(spin_config_store_contract, { SpinConfigStore::from_entries([ ("contract.key.a".to_string(), "value_a".to_string()), diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index 924a7e3..d5d1940 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -9,13 +9,14 @@ //! `put_bytes_with_ttl` store the value without expiry and emit a //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys -//! with no prefix or cursor support. Prefix filtering and pagination are -//! performed in-process after fetching all keys. A configurable cap -//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) guards against -//! unbounded allocations; when the total key count exceeds it the list is -//! silently truncated, a `log::warn!` is emitted, and a partial page is -//! returned so the caller can resume via cursor (matching the Axum adapter -//! behaviour for its scan-batch limit). +//! with no prefix or cursor support. All keys are fetched from the host, +//! then prefix filtering and pagination are performed in-process. A +//! configurable cap (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) +//! limits how many keys may be processed; when the store contains more keys +//! than the cap, `list_keys_page` returns `KvError::Validation` so the +//! caller can detect the condition and raise the cap via +//! [`SpinKvStore::with_max_list_keys`] rather than silently receiving +//! incomplete results. //! //! # Note //! @@ -31,7 +32,7 @@ use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; -/// Maximum number of keys fetched from the Spin KV host before +/// Maximum number of keys the Spin KV host may return before /// `list_keys_page` returns `KvError::Validation`. Overridable via /// [`SpinKvStore::with_max_list_keys`]. #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -67,11 +68,11 @@ impl SpinKvStore { Self::open("default") } - /// Override the maximum number of keys fetched during `list_keys_page`. + /// Override the maximum number of keys allowed during `list_keys_page`. /// /// When the Spin KV store contains more than `limit` keys, - /// `list_keys_page` returns `KvError::Validation` rather than - /// allocating an unbounded `Vec`. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. + /// `list_keys_page` returns `KvError::Validation` instead of returning + /// incomplete results. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. pub fn with_max_list_keys(mut self, limit: usize) -> Self { self.max_list_keys = limit; self @@ -130,18 +131,16 @@ impl KvStore for SpinKvStore { .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; if all_keys.len() > self.max_list_keys { - log::warn!( - "SpinKvStore: fetched {} keys, exceeding max_list_keys={}; \ - processing the first {} keys only. Use with_max_list_keys to scan the full store.", + return Err(KvError::Validation(format!( + "SpinKvStore: store contains {} keys, exceeding max_list_keys={}; \ + call with_max_list_keys to raise the cap", all_keys.len(), self.max_list_keys, - self.max_list_keys, - ); + ))); } let mut keys: Vec = all_keys .into_iter() - .take(self.max_list_keys) .filter(|k| k.starts_with(prefix)) .collect(); diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 6c3064f..b28d080 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -13,6 +13,11 @@ mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; +// SpinConfigStore is available without the `spin` feature flag because it +// gates its spin_sdk usage on `cfg(target_arch = "wasm32")` internally, +// allowing the InMemory test backend to compile on all targets. SpinKvStore +// and SpinSecretStore import spin_sdk types at the module level and therefore +// require `all(feature = "spin", target_arch = "wasm32")`. pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index b252b59..d26924d 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -35,8 +35,16 @@ impl Default for SpinSecretStore { #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for SpinSecretStore { - async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; + if !store_name.is_empty() { + // Spin's variable namespace is flat; named stores are not supported. + log::debug!( + "SpinSecretStore: store_name {:?} is ignored; \ + Spin uses a single flat variable namespace", + store_name + ); + } // Spin variable names are always lowercase. Normalise the key so that // conventional uppercase secret names (e.g. "STRIPE_KEY") work without // callers needing to know the Spin naming convention. From c001103cf03ed43ceaae847469d178a482c046b6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 25 Apr 2026 18:16:21 +0530 Subject: [PATCH 16/21] fix CI cache conflict --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 015a0f2..dfe5da6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,7 +114,11 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install wasm-bindgen test runner - run: cargo install wasm-bindgen-cli --version "${{ steps.wasm-bindgen-version.outputs.version }}" --locked + run: | + target="${{ steps.wasm-bindgen-version.outputs.version }}" + if ! wasm-bindgen --version 2>/dev/null | grep -qF "$target"; then + cargo install wasm-bindgen-cli --version "$target" --locked + fi - name: Fetch dependencies (locked) run: cargo fetch --locked From 3b995e53ccb0a4bed4e3aa99dccdf58340f59789 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 14:40:18 +0530 Subject: [PATCH 17/21] Fix test.yml to use matrix run --- .github/workflows/test.yml | 159 ++++++------------ .../edgezero-adapter-spin/src/config_store.rs | 8 +- .../src/key_value_store.rs | 23 +-- crates/edgezero-adapter-spin/src/lib.rs | 9 +- crates/edgezero-adapter-spin/src/request.rs | 19 ++- .../edgezero-adapter-spin/src/secret_store.rs | 10 +- .../edgezero-adapter-spin/tests/contract.rs | 141 ++++++++++------ 7 files changed, 187 insertions(+), 182 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfe5da6..6bc6c89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,12 +46,6 @@ jobs: - name: Add wasm targets run: rustup target add wasm32-wasip1 wasm32-unknown-unknown - - name: Setup Viceroy - run: | - if ! command -v viceroy &>/dev/null; then - cargo install viceroy --locked - fi - - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -61,12 +55,25 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" - - name: Check Spin wasm32 compilation - run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin - - cloudflare-wasm-tests: - name: cloudflare wasm tests + adapter-wasm-tests: + name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - adapter: cloudflare + target: wasm32-unknown-unknown + runner_env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER + runner_value: wasm-bindgen-test-runner + - adapter: fastly + target: wasm32-wasip1 + runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + runner_value: viceroy run + - adapter: spin + target: wasm32-wasip1 + runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + runner_value: wasmtime run steps: - uses: actions/checkout@v4 @@ -79,24 +86,25 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-cloudflare-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-cloudflare- + ${{ runner.os }}-cargo-${{ matrix.adapter }}- - name: Retrieve Rust version - id: rust-version-cloudflare + id: rust-version run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT shell: bash - name: Set up Rust tool chain uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: ${{ steps.rust-version-cloudflare.outputs.rust-version }} + toolchain: ${{ steps.rust-version.outputs.rust-version }} - - name: Add wasm32 target - run: rustup target add wasm32-unknown-unknown + - name: Add wasm target + run: rustup target add ${{ matrix.target }} - name: Resolve wasm-bindgen CLI version + if: matrix.adapter == 'cloudflare' id: wasm-bindgen-version shell: bash run: | @@ -114,106 +122,39 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install wasm-bindgen test runner + if: matrix.adapter == 'cloudflare' run: | - target="${{ steps.wasm-bindgen-version.outputs.version }}" - if ! wasm-bindgen --version 2>/dev/null | grep -qF "$target"; then - cargo install wasm-bindgen-cli --version "$target" --locked + required="${{ steps.wasm-bindgen-version.outputs.version }}" + if ! command -v wasm-bindgen-test-runner &>/dev/null \ + || ! wasm-bindgen --version 2>/dev/null | grep -q "$required"; then + cargo install wasm-bindgen-cli --version "$required" --locked --force fi - - name: Fetch dependencies (locked) - run: cargo fetch --locked - - - name: Run Cloudflare wasm tests - env: - CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner - run: cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract - - - name: Check Cloudflare wasm target - run: cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown - - fastly-wasm-tests: - name: fastly wasm tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Cache Cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-fastly-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-fastly- - - - name: Retrieve Rust version - id: rust-version-fastly - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - - name: Set up Rust tool chain - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: ${{ steps.rust-version-fastly.outputs.rust-version }} - - - name: Add wasm targets - run: rustup target add wasm32-wasip1 - - name: Setup Viceroy - run: cargo install viceroy --locked - - - name: Fetch dependencies (locked) - run: cargo fetch --locked + if: matrix.adapter == 'fastly' + run: | + if ! command -v viceroy &>/dev/null; then + cargo install viceroy --locked + fi - - name: Run Fastly wasm tests + - name: Setup Wasmtime + if: matrix.adapter == 'spin' env: - CARGO_TARGET_WASM32_WASIP1_RUNNER: "viceroy run" - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract - - - name: Check Fastly wasm target - run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 - - spin-adapter-tests: - name: spin adapter tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Cache Cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-spin-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-spin- - - - name: Retrieve Rust version - id: rust-version-spin - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: ${{ steps.rust-version-spin.outputs.rust-version }} - - - name: Add wasm32-wasip1 target - run: rustup target add wasm32-wasip1 + WASMTIME_VERSION: v44.0.0 + run: | + if ! command -v wasmtime &>/dev/null \ + || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then + curl https://wasmtime.dev/install.sh -sSf | bash -s -- "$WASMTIME_VERSION" + echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" + fi - name: Fetch dependencies (locked) run: cargo fetch --locked - - name: Run Spin adapter native tests - run: cargo test -p edgezero-adapter-spin --features spin + - name: Run ${{ matrix.adapter }} wasm tests + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract - - name: Check Spin wasm32 compilation - run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin + - name: Check ${{ matrix.adapter }} wasm target + run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 84089f5..7aeb126 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -42,14 +42,12 @@ impl Default for SpinConfigStore { } impl ConfigStore for SpinConfigStore { - // `key` is unused in the _Uninhabited arm on native non-test builds - #[allow(unused_variables)] - fn get(&self, key: &str) -> Result, ConfigStoreError> { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(target_arch = "wasm32")] SpinConfigBackend::Spin => { use spin_sdk::variables; - match variables::get(key) { + match variables::get(_key) { Ok(value) => Ok(Some(value)), Err(variables::Error::Undefined(_)) => Ok(None), Err(variables::Error::InvalidName(msg)) => { @@ -59,7 +57,7 @@ impl ConfigStore for SpinConfigStore { } } #[cfg(test)] - SpinConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), + SpinConfigBackend::InMemory(data) => Ok(data.get(_key).cloned()), #[cfg(not(any(target_arch = "wasm32", test)))] SpinConfigBackend::_Uninhabited(never) => match *never {}, } diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index d5d1940..292ae15 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -9,14 +9,15 @@ //! `put_bytes_with_ttl` store the value without expiry and emit a //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys -//! with no prefix or cursor support. All keys are fetched from the host, -//! then prefix filtering and pagination are performed in-process. A -//! configurable cap (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) -//! limits how many keys may be processed; when the store contains more keys -//! than the cap, `list_keys_page` returns `KvError::Validation` so the -//! caller can detect the condition and raise the cap via -//! [`SpinKvStore::with_max_list_keys`] rather than silently receiving -//! incomplete results. +//! with no prefix or cursor support. Every call to `list_keys_page` pays a +//! full host round-trip that fetches **all** keys in the store regardless of +//! prefix or page size — O(n) I/O per page. Prefix filtering and pagination +//! are performed in-process after the fetch. A configurable cap +//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) limits how many keys +//! may be processed; when the store contains more keys than the cap, +//! `list_keys_page` returns `KvError::Validation` so the caller can detect +//! the condition and raise the cap via [`SpinKvStore::with_max_list_keys`] +//! rather than silently receiving incomplete results. //! //! # Note //! @@ -155,9 +156,9 @@ impl KvStore for SpinKvStore { 0 }; - let remaining = &keys[start..]; - let page_keys: Vec = remaining.iter().take(limit).cloned().collect(); - let has_more = remaining.len() > limit; + let tail = &keys[start..]; + let page_keys: Vec = tail.iter().take(limit).cloned().collect(); + let has_more = tail.len() > limit; let next_cursor = if has_more { page_keys.last().cloned() } else { diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index b28d080..50c863e 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -18,14 +18,15 @@ mod response; // allowing the InMemory test backend to compile on all targets. SpinKvStore // and SpinSecretStore import spin_sdk types at the module level and therefore // require `all(feature = "spin", target_arch = "wasm32")`. -pub use config_store::SpinConfigStore; -pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod key_value_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use key_value_store::SpinKvStore; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] mod secret_store; + +pub use config_store::SpinConfigStore; +pub use context::SpinRequestContext; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use key_value_store::SpinKvStore; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index c8a305f..e606089 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -95,9 +95,24 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { let mut core_request = into_core_request(req).await?; diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index d26924d..f99d184 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -45,9 +45,13 @@ impl SecretStore for SpinSecretStore { store_name ); } - // Spin variable names are always lowercase. Normalise the key so that - // conventional uppercase secret names (e.g. "STRIPE_KEY") work without - // callers needing to know the Spin naming convention. + // Spin variable names must be lowercase. Normalise via ascii_lowercase + // so that SCREAMING_SNAKE_CASE keys (e.g. "STRIPE_KEY" → "stripe_key") + // work without callers knowing the Spin convention. Note: only + // UPPER_SNAKE → lower_snake is safe; camelCase or mixed-case keys will + // be lowercased in a way that may not match any declared variable + // (e.g. "stripeKey" → "stripekey"). Document accepted key formats at + // the call site. let lower = key.to_ascii_lowercase(); match variables::get(&lower) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index fb27673..247c1ac 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -13,20 +13,36 @@ use futures::executor::block_on; use futures::stream; use std::sync::Arc; -struct FixedConfigStore(&'static str); +/// Config store that returns a value only for the expected key. +struct FixedConfigStore { + key: &'static str, + value: &'static str, +} impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_string())) + fn get(&self, key: &str) -> Result, ConfigStoreError> { + if key == self.key { + Ok(Some(self.value.to_string())) + } else { + Ok(None) + } } } -struct StubKvStore; +/// KV store that returns a fixed value for one key; everything else is absent. +struct FixedKvStore { + key: &'static str, + value: &'static [u8], +} #[async_trait::async_trait(?Send)] -impl KvStore for StubKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) +impl KvStore for FixedKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + if key == self.key { + Ok(Some(Bytes::from_static(self.value))) + } else { + Ok(None) + } } async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { Ok(()) @@ -42,8 +58,8 @@ impl KvStore for StubKvStore { async fn delete(&self, _key: &str) -> Result<(), KvError> { Ok(()) } - async fn exists(&self, _key: &str) -> Result { - Ok(false) + async fn exists(&self, key: &str) -> Result { + Ok(key == self.key) } async fn list_keys_page( &self, @@ -52,18 +68,26 @@ impl KvStore for StubKvStore { _limit: usize, ) -> Result { Ok(KvPage { - keys: vec![], + keys: vec![self.key.to_string()], cursor: None, }) } } -struct StubSecretStore; +/// Secret store that returns a fixed value for one (store, key) pair. +struct FixedSecretStore { + key: &'static str, + value: &'static [u8], +} #[async_trait::async_trait(?Send)] -impl SecretStore for StubSecretStore { - async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { - Ok(None) +impl SecretStore for FixedSecretStore { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + if key == self.key { + Ok(Some(Bytes::from_static(self.value))) + } else { + Ok(None) + } } } @@ -111,28 +135,36 @@ fn build_test_app() -> App { Ok(response) } - async fn kv_presence(ctx: RequestContext) -> Result { - let body = Body::text(if ctx.kv_handle().is_some() { - "yes" + async fn kv_value(ctx: RequestContext) -> Result { + let value = if let Some(handle) = ctx.kv_handle() { + match handle.get_bytes("test-key").await { + Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(), + Ok(None) => "missing".to_string(), + Err(_) => "error".to_string(), + } } else { - "no" - }); + "no-handle".to_string() + }; let response = response_builder() .status(StatusCode::OK) - .body(body) + .body(Body::text(value)) .expect("response"); Ok(response) } - async fn secret_presence(ctx: RequestContext) -> Result { - let body = Body::text(if ctx.secret_handle().is_some() { - "yes" + async fn secret_value(ctx: RequestContext) -> Result { + let value = if let Some(handle) = ctx.secret_handle() { + match handle.get_bytes("default", "test-secret").await { + Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(), + Ok(None) => "missing".to_string(), + Err(_) => "error".to_string(), + } } else { - "no" - }); + "no-handle".to_string() + }; let response = response_builder() .status(StatusCode::OK) - .body(body) + .body(Body::text(value)) .expect("response"); Ok(response) } @@ -142,8 +174,8 @@ fn build_test_app() -> App { .post("/mirror", mirror_body) .get("/stream", stream_response) .get("/config", config_value) - .get("/has-kv", kv_presence) - .get("/has-secret", secret_presence) + .get("/kv-value", kv_value) + .get("/secret-value", secret_value) .build(); App::new(router) @@ -231,7 +263,7 @@ fn router_dispatches_streaming_route() { // --------------------------------------------------------------------------- #[test] -fn config_store_handle_is_accessible_from_handler() { +fn config_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") @@ -240,9 +272,10 @@ fn config_store_handle_is_accessible_from_handler() { .expect("request"); request .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore( - "hello-spin", - )))); + .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore { + key: "greeting", + value: "hello-spin", + }))); let response = block_on(app.router().oneshot(request)); @@ -251,39 +284,45 @@ fn config_store_handle_is_accessible_from_handler() { } #[test] -fn kv_handle_is_accessible_from_handler() { +fn kv_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") - .uri("http://example.com/has-kv") + .uri("http://example.com/kv-value") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(KvHandle::new(Arc::new(StubKvStore))); + .insert(KvHandle::new(Arc::new(FixedKvStore { + key: "test-key", + value: b"kv-payload", + }))); let response = block_on(app.router().oneshot(request)); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"yes"); + assert_eq!(response.body().as_bytes(), b"kv-payload"); } #[test] -fn secret_handle_is_accessible_from_handler() { +fn secret_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") - .uri("http://example.com/has-secret") + .uri("http://example.com/secret-value") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(SecretHandle::new(Arc::new(StubSecretStore))); + .insert(SecretHandle::new(Arc::new(FixedSecretStore { + key: "test-secret", + value: b"s3cr3t", + }))); let response = block_on(app.router().oneshot(request)); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"yes"); + assert_eq!(response.body().as_bytes(), b"s3cr3t"); } #[test] @@ -295,24 +334,30 @@ fn missing_store_handles_return_absent_values_in_handler() { .uri("http://example.com/config") .body(Body::empty()) .expect("request"); - let config_response = block_on(app.router().oneshot(config_req)); - assert_eq!(config_response.body().as_bytes(), b"missing"); + assert_eq!( + block_on(app.router().oneshot(config_req)).body().as_bytes(), + b"missing" + ); let kv_req = request_builder() .method("GET") - .uri("http://example.com/has-kv") + .uri("http://example.com/kv-value") .body(Body::empty()) .expect("request"); - let kv_response = block_on(app.router().oneshot(kv_req)); - assert_eq!(kv_response.body().as_bytes(), b"no"); + assert_eq!( + block_on(app.router().oneshot(kv_req)).body().as_bytes(), + b"no-handle" + ); let secret_req = request_builder() .method("GET") - .uri("http://example.com/has-secret") + .uri("http://example.com/secret-value") .body(Body::empty()) .expect("request"); - let secret_response = block_on(app.router().oneshot(secret_req)); - assert_eq!(secret_response.body().as_bytes(), b"no"); + assert_eq!( + block_on(app.router().oneshot(secret_req)).body().as_bytes(), + b"no-handle" + ); } // --------------------------------------------------------------------------- From 46442252958b4c632c0fced86523d1a9727384bd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 14:54:02 +0530 Subject: [PATCH 18/21] Fix pin viceroy pin and wasmtime install flag --- .github/workflows/test.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bc6c89..e29f70e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,9 +132,12 @@ jobs: - name: Setup Viceroy if: matrix.adapter == 'fastly' + env: + VICEROY_VERSION: 0.16.5 run: | - if ! command -v viceroy &>/dev/null; then - cargo install viceroy --locked + if ! command -v viceroy &>/dev/null \ + || ! viceroy --version 2>/dev/null | grep -qF "$VICEROY_VERSION"; then + cargo install viceroy --version "$VICEROY_VERSION" --locked fi - name: Setup Wasmtime @@ -144,7 +147,7 @@ jobs: run: | if ! command -v wasmtime &>/dev/null \ || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then - curl https://wasmtime.dev/install.sh -sSf | bash -s -- "$WASMTIME_VERSION" + curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version "$WASMTIME_VERSION" echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" fi From ff0633bee9a81488dc5bbe38d30c2f6ddef922a6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 15:05:29 +0530 Subject: [PATCH 19/21] Fix type mismatch for wasm32-wasip1 --- crates/edgezero-adapter-spin/tests/contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 247c1ac..f943287 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -387,7 +387,7 @@ mod wasm { assert_eq!(*spin_response.status(), 201); let header = spin_response .headers() - .find(|(name, _)| name == "x-edgezero-res"); + .find(|(name, _)| *name == "x-edgezero-res"); assert!(header.is_some()); }); } From c9d1e25a0b1e3a2c00969d4c2826074b70ab9a8f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 12:07:26 +0530 Subject: [PATCH 20/21] Address pr review findings --- crates/edgezero-adapter-spin/src/lib.rs | 2 +- crates/edgezero-adapter-spin/src/request.rs | 45 ++++++++++++--------- crates/edgezero-core/src/manifest.rs | 15 +++++-- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 50c863e..0c86163 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -30,7 +30,7 @@ pub use key_value_store::SpinKvStore; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use request::{dispatch, into_core_request}; +pub use request::{dispatch, dispatch_with_kv_label, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index e606089..21deb9c 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -90,37 +90,41 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { + dispatch_with_kv_label(app, req, "default").await +} + /// Dispatch a Spin request through the EdgeZero router and return -/// a Spin-compatible response. +/// a Spin-compatible response, opening the KV store under `kv_label`. /// /// Injects all available stores into request extensions: /// - `ConfigStoreHandle` backed by `SpinConfigStore` (Spin component variables) -/// - `KvHandle` backed by `SpinKvStore` opened on the `"default"` label (best-effort; +/// - `KvHandle` backed by `SpinKvStore` opened on `kv_label` (best-effort; /// logged and omitted if the label is not declared in `spin.toml`) /// - `SecretHandle` backed by `SpinSecretStore` (Spin component variables) /// -/// # KV label limitation -/// -/// Only the `"default"` KV label is opened automatically. If your `spin.toml` -/// uses a different label, skip `run_app` and call `into_core_request` / -/// `from_core_response` directly, inserting your own `KvHandle`: -/// -/// ```ignore -/// let mut req = into_core_request(incoming).await?; -/// req.extensions_mut().insert(KvHandle::new(Arc::new( -/// SpinKvStore::open("my-label")?, -/// ))); -/// let resp = app.router().oneshot(req).await; -/// Ok(from_core_response(resp).await?) -/// ``` -pub async fn dispatch(app: &App, req: IncomingRequest) -> anyhow::Result { +/// Pass the label that matches your `spin.toml` `key_value_stores` entry. +/// If `[stores.kv.adapters.spin].name` in `edgezero.toml` is `"my-store"`, +/// that same string must appear in `spin.toml` and must be passed here. +pub async fn dispatch_with_kv_label( + app: &App, + req: IncomingRequest, + kv_label: &str, +) -> anyhow::Result { let mut core_request = into_core_request(req).await?; core_request .extensions_mut() .insert(ConfigStoreHandle::new(Arc::new(SpinConfigStore::new()))); - match SpinKvStore::open_default() { + match SpinKvStore::open(kv_label) { Ok(store) => { core_request .extensions_mut() @@ -128,8 +132,9 @@ pub async fn dispatch(app: &App, req: IncomingRequest) -> anyhow::Result { log::warn!( - "SpinKvStore: could not open default KV store (label \"default\"); \ - KV operations will be unavailable: {e}" + "SpinKvStore: could not open KV store (label {:?}); \ + KV operations will be unavailable: {e}", + kv_label ); } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c6f7e27..fed7cd1 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -54,7 +54,11 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly", "spin"]; +// Spin config values come from Spin component variables (flat namespace); +// there is no runtime store-name concept, so adapter-name overrides for spin +// would be silently ignored. Keep spin out of the allowed set to surface +// misconfiguration at validation time rather than at runtime. +const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; #[derive(Debug, Deserialize, Validate)] pub struct Manifest { @@ -1426,15 +1430,18 @@ name = "APP_CONFIG" } #[test] - fn config_store_spin_adapter_key_passes_validation() { + fn config_store_spin_adapter_key_fails_validation() { + // Spin config values come from component variables; there is no + // runtime store-name concept, so a spin adapter override would be + // silently ignored. Validation rejects it to surface the mistake early. let src = r#" [stores.config.adapters.spin] name = "SPIN_CONFIG" "#; let manifest: Manifest = toml::from_str(src).expect("should parse"); assert!( - manifest.validate().is_ok(), - "spin config store adapter key should pass validation" + manifest.validate().is_err(), + "spin config store adapter key should fail validation" ); } From d0d6d604bec8b0768f1d573f05ddd0657c1819cd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 20 May 2026 13:30:42 +0530 Subject: [PATCH 21/21] fix: address spin store review feedback --- .github/workflows/test.yml | 4 +- .../edgezero-adapter-spin/src/config_store.rs | 14 +-- .../src/key_value_store.rs | 103 +++-------------- crates/edgezero-adapter-spin/src/lib.rs | 107 ++++++++++++++++-- crates/edgezero-adapter-spin/src/request.rs | 106 ++++++++++++++--- .../src/templates/src/lib.rs.hbs | 2 +- crates/edgezero-core/src/manifest.rs | 4 +- crates/edgezero-core/src/secret_store.rs | 10 +- docs/guide/configuration.md | 12 +- docs/guide/kv.md | 27 ++++- .../crates/app-demo-adapter-spin/spin.toml | 2 +- .../crates/app-demo-adapter-spin/src/lib.rs | 2 +- examples/app-demo/edgezero.toml | 5 + 13 files changed, 265 insertions(+), 133 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ebb3c6..b32fa75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,6 +83,7 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ + ~/.wasmtime/bin/ target/ key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | @@ -143,10 +144,11 @@ jobs: env: WASMTIME_VERSION: v44.0.0 run: | + export PATH="$HOME/.wasmtime/bin:$PATH" + echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" if ! command -v wasmtime &>/dev/null \ || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version "$WASMTIME_VERSION" - echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" fi - name: Fetch dependencies (locked) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 7aeb126..a6ddd92 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -8,18 +8,18 @@ pub struct SpinConfigStore { } enum SpinConfigBackend { - #[cfg(target_arch = "wasm32")] + #[cfg(all(feature = "spin", target_arch = "wasm32"))] Spin, #[cfg(test)] InMemory(std::collections::HashMap), - /// Never constructed; keeps the enum inhabited in non-wasm32, non-test builds. - #[cfg(not(any(target_arch = "wasm32", test)))] + /// Never constructed; keeps the enum inhabited outside production Spin and tests. + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] _Uninhabited(std::convert::Infallible), } impl SpinConfigStore { /// Create a new `SpinConfigStore` using the Spin variables API. - #[cfg(target_arch = "wasm32")] + #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub fn new() -> Self { Self { inner: SpinConfigBackend::Spin, @@ -34,7 +34,7 @@ impl SpinConfigStore { } } -#[cfg(target_arch = "wasm32")] +#[cfg(all(feature = "spin", target_arch = "wasm32"))] impl Default for SpinConfigStore { fn default() -> Self { Self::new() @@ -44,7 +44,7 @@ impl Default for SpinConfigStore { impl ConfigStore for SpinConfigStore { fn get(&self, _key: &str) -> Result, ConfigStoreError> { match &self.inner { - #[cfg(target_arch = "wasm32")] + #[cfg(all(feature = "spin", target_arch = "wasm32"))] SpinConfigBackend::Spin => { use spin_sdk::variables; match variables::get(_key) { @@ -58,7 +58,7 @@ impl ConfigStore for SpinConfigStore { } #[cfg(test)] SpinConfigBackend::InMemory(data) => Ok(data.get(_key).cloned()), - #[cfg(not(any(target_arch = "wasm32", test)))] + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] SpinConfigBackend::_Uninhabited(never) => match *never {}, } } diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index 292ae15..66e199f 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -6,18 +6,10 @@ //! # Limitations //! //! - **TTL**: The Spin KV API has no TTL support. Calls to -//! `put_bytes_with_ttl` store the value without expiry and emit a -//! `log::warn!`. +//! `put_bytes_with_ttl` return `KvError::Validation` without writing. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys -//! with no prefix or cursor support. Every call to `list_keys_page` pays a -//! full host round-trip that fetches **all** keys in the store regardless of -//! prefix or page size — O(n) I/O per page. Prefix filtering and pagination -//! are performed in-process after the fetch. A configurable cap -//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) limits how many keys -//! may be processed; when the store contains more keys than the cap, -//! `list_keys_page` returns `KvError::Validation` so the caller can detect -//! the condition and raise the cap via [`SpinKvStore::with_max_list_keys`] -//! rather than silently receiving incomplete results. +//! with no prefix or cursor support. `list_keys_page` therefore returns +//! `KvError::Validation` instead of materializing the whole store. //! //! # Note //! @@ -33,12 +25,6 @@ use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; -/// Maximum number of keys the Spin KV host may return before -/// `list_keys_page` returns `KvError::Validation`. Overridable via -/// [`SpinKvStore::with_max_list_keys`]. -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub const DEFAULT_MAX_LIST_KEYS: usize = 10_000; - /// KV store backed by the Spin KV API. /// /// Wraps a `spin_sdk::key_value::Store` handle obtained via @@ -46,7 +32,6 @@ pub const DEFAULT_MAX_LIST_KEYS: usize = 10_000; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinKvStore { store: spin_sdk::key_value::Store, - max_list_keys: usize, } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -58,25 +43,12 @@ impl SpinKvStore { pub fn open(label: &str) -> Result { let store = spin_sdk::key_value::Store::open(label) .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))?; - Ok(Self { - store, - max_list_keys: DEFAULT_MAX_LIST_KEYS, - }) + Ok(Self { store }) } - /// Open the default Spin KV store (label `"default"`). + /// Open the default EdgeZero KV store label (`"EDGEZERO_KV"`). pub fn open_default() -> Result { - Self::open("default") - } - - /// Override the maximum number of keys allowed during `list_keys_page`. - /// - /// When the Spin KV store contains more than `limit` keys, - /// `list_keys_page` returns `KvError::Validation` instead of returning - /// incomplete results. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. - pub fn with_max_list_keys(mut self, limit: usize) -> Self { - self.max_list_keys = limit; - self + Self::open(edgezero_core::manifest::DEFAULT_KV_STORE_NAME) } } @@ -98,14 +70,13 @@ impl KvStore for SpinKvStore { async fn put_bytes_with_ttl( &self, - key: &str, - value: Bytes, + _key: &str, + _value: Bytes, _ttl: Duration, ) -> Result<(), KvError> { - log::warn!("SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry"); - self.store - .set(key, value.as_ref()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) + Err(KvError::Validation( + "Spin KV does not support TTL; use put_bytes for non-expiring values".to_string(), + )) } async fn delete(&self, key: &str) -> Result<(), KvError> { @@ -122,53 +93,13 @@ impl KvStore for SpinKvStore { async fn list_keys_page( &self, - prefix: &str, - cursor: Option<&str>, - limit: usize, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, ) -> Result { - let all_keys = self - .store - .get_keys() - .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; - - if all_keys.len() > self.max_list_keys { - return Err(KvError::Validation(format!( - "SpinKvStore: store contains {} keys, exceeding max_list_keys={}; \ - call with_max_list_keys to raise the cap", - all_keys.len(), - self.max_list_keys, - ))); - } - - let mut keys: Vec = all_keys - .into_iter() - .filter(|k| k.starts_with(prefix)) - .collect(); - - keys.sort(); - - // Advance past all keys <= last_key (the cursor). - let start = if let Some(last_key) = cursor { - keys.iter() - .position(|k| k.as_str() > last_key) - .unwrap_or(keys.len()) - } else { - 0 - }; - - let tail = &keys[start..]; - let page_keys: Vec = tail.iter().take(limit).cloned().collect(); - let has_more = tail.len() > limit; - let next_cursor = if has_more { - page_keys.last().cloned() - } else { - None - }; - - Ok(KvPage { - keys: page_keys, - cursor: next_cursor, - }) + Err(KvError::Validation( + "Spin KV key listing is unsupported because Store::get_keys() is unbounded".to_string(), + )) } } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 0c86163..ce95030 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -3,7 +3,8 @@ #[cfg(feature = "cli")] pub mod cli; -pub mod config_store; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod config_store; mod context; mod decompress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -13,16 +14,17 @@ mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; -// SpinConfigStore is available without the `spin` feature flag because it -// gates its spin_sdk usage on `cfg(target_arch = "wasm32")` internally, -// allowing the InMemory test backend to compile on all targets. SpinKvStore -// and SpinSecretStore import spin_sdk types at the module level and therefore +// SpinConfigStore is available without the `spin` feature flag because its +// production spin_sdk backend is feature-gated internally, allowing the +// InMemory test backend to compile on all targets. SpinKvStore and +// SpinSecretStore import spin_sdk types at the module level and therefore // require `all(feature = "spin", target_arch = "wasm32")`. #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod key_value_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod secret_store; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -30,7 +32,7 @@ pub use key_value_store::SpinKvStore; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use request::{dispatch, dispatch_with_kv_label, into_core_request}; +pub use request::{dispatch, dispatch_with_kv_label, dispatch_with_manifest, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -69,10 +71,38 @@ impl AppExt for edgezero_core::app::App { } } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpinStoreSettings { + pub(crate) config_enabled: bool, + pub(crate) kv_label: String, + pub(crate) kv_required: bool, + pub(crate) secrets_enabled: bool, +} + +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +pub(crate) fn resolve_store_settings( + manifest: &edgezero_core::manifest::Manifest, + hook_has_config_store: bool, +) -> SpinStoreSettings { + SpinStoreSettings { + config_enabled: hook_has_config_store || manifest.stores.config.is_some(), + kv_label: manifest + .kv_store_name(edgezero_core::app::SPIN_ADAPTER) + .to_string(), + kv_required: manifest.stores.kv.is_some(), + secrets_enabled: manifest.secret_store_enabled(edgezero_core::app::SPIN_ADAPTER), + } +} + /// Convenience entry point: build the app from `Hooks`, dispatch the /// incoming Spin request through the EdgeZero router, and return the /// response. /// +/// `manifest_src` must be the contents of `edgezero.toml`. `run_app` uses it +/// to resolve KV, config-store, and secret-store manifest gating before +/// dispatching. +/// /// Usage in a Spin component: /// /// ```ignore @@ -81,11 +111,12 @@ impl AppExt for edgezero_core::app::App { /// /// #[http_component] /// async fn handle(req: spin_sdk::http::IncomingRequest) -> anyhow::Result { -/// edgezero_adapter_spin::run_app::(req).await +/// edgezero_adapter_spin::run_app::(include_str!("../../../edgezero.toml"), req).await /// } /// ``` #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub async fn run_app( + manifest_src: &str, req: spin_sdk::http::IncomingRequest, ) -> anyhow::Result { // Use `let _ =` instead of `.expect()` because Spin calls @@ -93,6 +124,66 @@ pub async fn run_app( // `log::set_logger` returns Err on the second call — `.expect()` // would panic on every subsequent request. let _ = init_logger(); + let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let settings = resolve_store_settings(manifest_loader.manifest(), A::config_store().is_some()); let app = A::build_app(); - dispatch(&app, req).await + request::dispatch_with_store_settings(&app, req, &settings).await +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::manifest::ManifestLoader; + + fn resolve_settings(src: &str, hook_has_config_store: bool) -> SpinStoreSettings { + let manifest = ManifestLoader::load_from_str(src); + resolve_store_settings(manifest.manifest(), hook_has_config_store) + } + + #[test] + fn store_settings_default_to_optional_kv_without_config_or_secrets() { + let settings = resolve_settings("", false); + + assert_eq!( + settings.kv_label, + edgezero_core::manifest::DEFAULT_KV_STORE_NAME + ); + assert!(!settings.kv_required); + assert!(!settings.config_enabled); + assert!(!settings.secrets_enabled); + } + + #[test] + fn store_settings_resolve_spin_manifest_overrides() { + let settings = resolve_settings( + r#" +[stores.kv] +name = "GLOBAL_KV" + +[stores.kv.adapters.spin] +name = "SPIN_KV" + +[stores.config] + +[stores.secrets] +enabled = false + +[stores.secrets.adapters.spin] +enabled = true +"#, + false, + ); + + assert_eq!(settings.kv_label, "SPIN_KV"); + assert!(settings.kv_required); + assert!(settings.config_enabled); + assert!(settings.secrets_enabled); + } + + #[test] + fn store_settings_honor_hook_config_metadata_without_manifest_config_section() { + let settings = resolve_settings("", true); + + assert!(settings.config_enabled); + } } diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 21deb9c..34c3f1b 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -6,6 +6,7 @@ use crate::key_value_store::SpinKvStore; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; use crate::secret_store::SpinSecretStore; +use crate::SpinStoreSettings; use edgezero_core::app::App; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; @@ -16,6 +17,13 @@ use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; use spin_sdk::http::IncomingRequest; +#[derive(Default)] +pub(crate) struct Stores { + pub(crate) config_store: Option, + pub(crate) kv: Option, + pub(crate) secrets: Option, +} + /// Convert a Spin `IncomingRequest` into an EdgeZero core `Request`. /// /// Reads the full body into a buffered `Body::Once`, inserts @@ -93,14 +101,43 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { dispatch_with_kv_label(app, req, "default").await } +/// Dispatch a Spin request using store settings resolved from `edgezero.toml`. +/// +/// This is the manifest-aware manual entry point for callers that already have +/// an [`App`]. The `Hooks`-based [`crate::run_app`] helper uses the same +/// resolution path and additionally honors `Hooks::config_store()` metadata +/// generated by the `app!` macro. +pub async fn dispatch_with_manifest( + app: &App, + manifest_src: &str, + req: IncomingRequest, +) -> anyhow::Result { + let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let settings = crate::resolve_store_settings(manifest_loader.manifest(), false); + dispatch_with_store_settings(app, req, &settings).await +} + +pub(crate) async fn dispatch_with_store_settings( + app: &App, + req: IncomingRequest, + settings: &SpinStoreSettings, +) -> anyhow::Result { + let stores = Stores { + config_store: resolve_config_handle(settings.config_enabled), + kv: resolve_kv_handle(&settings.kv_label, settings.kv_required)?, + secrets: resolve_secret_handle(settings.secrets_enabled), + }; + dispatch_with_handles(app, req, stores).await +} + /// Dispatch a Spin request through the EdgeZero router and return /// a Spin-compatible response, opening the KV store under `kv_label`. /// @@ -117,34 +154,67 @@ pub async fn dispatch_with_kv_label( app: &App, req: IncomingRequest, kv_label: &str, +) -> anyhow::Result { + let stores = Stores { + config_store: resolve_config_handle(true), + kv: resolve_kv_handle(kv_label, false)?, + secrets: resolve_secret_handle(true), + }; + dispatch_with_handles(app, req, stores).await +} + +pub(crate) async fn dispatch_with_handles( + app: &App, + req: IncomingRequest, + stores: Stores, ) -> anyhow::Result { let mut core_request = into_core_request(req).await?; + if let Some(handle) = stores.config_store { + core_request.extensions_mut().insert(handle); + } + if let Some(handle) = stores.kv { + core_request.extensions_mut().insert(handle); + } + if let Some(handle) = stores.secrets { + core_request.extensions_mut().insert(handle); + } + let response = app.router().oneshot(core_request).await; + Ok(from_core_response(response).await?) +} - core_request - .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(SpinConfigStore::new()))); +fn resolve_config_handle(config_enabled: bool) -> Option { + if !config_enabled { + return None; + } + Some(ConfigStoreHandle::new(Arc::new(SpinConfigStore::new()))) +} +fn resolve_kv_handle(kv_label: &str, kv_required: bool) -> anyhow::Result> { match SpinKvStore::open(kv_label) { - Ok(store) => { - core_request - .extensions_mut() - .insert(KvHandle::new(Arc::new(store))); - } + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), Err(e) => { + if kv_required { + return Err(anyhow::anyhow!( + "Spin KV store '{}' is explicitly configured but could not be opened: {}", + kv_label, + e + )); + } log::warn!( "SpinKvStore: could not open KV store (label {:?}); \ KV operations will be unavailable: {e}", kv_label ); + Ok(None) } } +} - core_request - .extensions_mut() - .insert(SecretHandle::new(Arc::new(SpinSecretStore::new()))); - - let response = app.router().oneshot(core_request).await; - Ok(from_core_response(response).await?) +fn resolve_secret_handle(secrets_enabled: bool) -> Option { + if !secrets_enabled { + return None; + } + Some(SecretHandle::new(Arc::new(SpinSecretStore::new()))) } fn into_core_method( diff --git a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs index 64b0fa2..6793a51 100644 --- a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs @@ -8,5 +8,5 @@ use {{proj_core_mod}}::App; #[cfg(target_arch = "wasm32")] #[http_component] async fn handle(req: IncomingRequest) -> anyhow::Result { - edgezero_adapter_spin::run_app::(req).await + edgezero_adapter_spin::run_app::(include_str!("../../../edgezero.toml"), req).await } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index fed7cd1..4371a6f 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -408,7 +408,9 @@ pub struct ManifestConfigStoreConfig { #[validate(length(min = 1))] pub name: Option, /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, `fastly`, or `spin`). + /// (`axum`, `cloudflare`, or `fastly`). Spin config uses component + /// variables in a flat namespace, so `stores.config.adapters.spin` is + /// rejected during validation. #[serde(default)] #[validate(nested)] #[validate(custom(function = "validate_config_store_adapter_keys"))] diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index 5ecd699..30cc365 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -80,9 +80,13 @@ pub const MAX_NAME_LEN: usize = 512; /// Access secrets across multiple named stores. /// -/// Platforms with a single flat namespace (env vars, in-memory test stores) -/// implement this by keying on `"{store_name}/{key}"`. -/// Platforms with named stores (Fastly, Spin) open a store-specific handle +/// Platforms with a single flat namespace implement this differently: +/// - Env vars and in-memory test stores key values on `"{store_name}/{key}"`. +/// - Cloudflare and Spin ignore `store_name`; each platform exposes one flat +/// runtime namespace. Spin reads component variables, which must be declared +/// with lowercase variable names in `spin.toml`. +/// +/// Platforms with named stores, such as Fastly, open a store-specific handle /// per `store_name`. #[async_trait(?Send)] pub trait SecretStore: Send + Sync { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6dc17c0..99ca217 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -175,9 +175,12 @@ name = "MY_FASTLY_SECRETS" - Axum reads secrets from process environment variables of the same name. - Fastly opens the configured secret store name from `fastly.toml`. - Cloudflare reads Worker Secrets individually; the configured `name` is metadata only. +- Spin reads component variables from `spin.toml` in a single flat namespace. The configured `name` + and named secret-store overrides are metadata only for Spin; variable names must be declared in + lowercase and are looked up through the Spin variables API. If `[stores.secrets]` is omitted, the `Secrets` extractor is not attached for -that adapter. +that adapter. The Spin `run_app` helper honors this manifest gate. ## Stores Section @@ -207,10 +210,15 @@ Runtime behavior by adapter: - Fastly reads from a Fastly Config Store resource link. - Cloudflare reads from a single JSON string binding in `wrangler.toml [vars]`. - Axum reads only the env vars declared in `defaults`, then falls back to `defaults`. +- Spin reads component variables declared in `spin.toml`. Spin variables use a flat namespace with + lowercase names; there is no config-store binding name, so `[stores.config.adapters.spin]` is + rejected during manifest validation. When `[stores.config]` is present, the `app!` macro generates config-store metadata on the `App` type. The standard adapter `run_app` helpers use that metadata to inject a config-store handle into -request extensions automatically, so handlers can call `ctx.config_store()`. +request extensions automatically, so handlers can call `ctx.config_store()`. The Spin `run_app` +helper also reads the embedded manifest and injects the config store only when `[stores.config]` +exists or macro-generated config-store metadata is present. Treat config-store keys like API surface: validate or allowlist any user-controlled lookup before calling `ctx.config_store()?.get(...)`. diff --git a/docs/guide/kv.md b/docs/guide/kv.md index 7716d98..8d7cb32 100644 --- a/docs/guide/kv.md +++ b/docs/guide/kv.md @@ -1,6 +1,6 @@ # Key-Value Store -EdgeZero provides a unified interface for Key-Value (KV) storage, abstracting differences between Fastly KV Store and Cloudflare Workers KV. +EdgeZero provides a unified interface for Key-Value (KV) storage, abstracting differences between Axum local storage, Fastly KV Store, Cloudflare Workers KV, and Spin KV. ## End-to-End Example @@ -68,7 +68,7 @@ The `KvHandle` provides typed helpers that automatically serialize/deserialize J - `get(key)`: Returns `Option`. - `get_or(key, default)`: Returns the value or a fallback. - `put(key, value)`: Stores a value. -- `put_with_ttl(key, value, ttl)`: Stores a value that expires after `ttl`. +- `put_with_ttl(key, value, ttl)`: Stores a value that expires after `ttl` on adapters that support TTL. - `delete(key)`: Removes a value. - `exists(key)`: Checks if a key is present. - `list_keys_page(prefix, cursor, limit)`: Lists keys in a bounded page. Pass the returned cursor back unchanged with the same prefix to fetch the next page. @@ -86,7 +86,7 @@ Use it only when approximate values are acceptable (e.g. visit counters, feature For strict correctness, use a transactional data store. ::: -Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. +Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. The Spin adapter returns `KvError::Validation` for key listing because Spin's current `Store::get_keys()` API is unbounded. ## Platform Specifics @@ -124,13 +124,32 @@ Key listing is paginated by design. This avoids buffering an unbounded number of The `binding` name MUST match the store name configured in `edgezero.toml` (default: `"EDGEZERO_KV"`). +- **Spin**: Requires a `key_value_stores` label in `spin.toml`. + + ```toml + [component.my-app] + key_value_stores = ["default"] + ``` + + The label MUST match the store name configured in `edgezero.toml`, or the Spin-specific override. Spin's local runtime auto-provisions the `"default"` label; custom labels require a Spin runtime config or cloud link. + + ```toml + [stores.kv] + name = "EDGEZERO_KV" + + [stores.kv.adapters.spin] + name = "default" + ``` + + `edgezero_adapter_spin::run_app` reads `edgezero.toml` and opens the resolved Spin label. Low-level manual dispatch helpers do not read the manifest. + ### Consistency Both Fastly and Cloudflare KV stores are **eventually consistent**. - A value written at one edge location may not be immediately visible at another. - `read_modify_write()` is **not atomic**. Concurrent updates to the same key may result in lost writes. -- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** across all adapters. +- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Validation` without writing the value. ## Limits & Validation diff --git a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml index d5c7595..e0bf005 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml @@ -22,7 +22,7 @@ component = "app-demo" [component.app-demo] source = "../../target/wasm32-wasip1/release/app_demo_adapter_spin.wasm" allowed_outbound_hosts = ["https://*:*"] -# KV store bound to the "default" label; SpinKvStore opens this label by default. +# KV store label must match [stores.kv.adapters.spin] in edgezero.toml. key_value_stores = ["default"] [component.app-demo.variables] diff --git a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs index 8c68a33..0a102e1 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs @@ -8,5 +8,5 @@ use spin_sdk::http_component; #[cfg(target_arch = "wasm32")] #[http_component] async fn handle(req: IncomingRequest) -> anyhow::Result { - edgezero_adapter_spin::run_app::(req).await + edgezero_adapter_spin::run_app::(include_str!("../../../edgezero.toml"), req).await } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index c6be1f4..50a33f1 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -113,6 +113,11 @@ description = "Echo an allowlisted smoke-test secret value (smoke-test only — # [stores.kv.adapters.cloudflare] # name = "CF_KV_BINDING" +[stores.kv.adapters.spin] +# Spin's local runtime auto-provisions the "default" label. Custom labels +# require a Spin runtime config or cloud link. +name = "default" + [stores.secrets] # Uses the default name "EDGEZERO_SECRETS". # Axum reads secrets from environment variables of the same name.