From 8c5104e5f3ac23f795f1d7794efa4d24399ddded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:19:08 +0800 Subject: [PATCH 1/8] feat(durable object): expose `SyncKvStorage` - add wasm bindings for `SyncKvStorage` - expose `kv()` on `DurableObjectStorage` - add missing `startAfter` to `ListOptions` (present in workerd) --- Cargo.lock | 9 ++ examples/sync-kv/Cargo.toml | 12 ++ examples/sync-kv/src/lib.rs | 59 ++++++++ examples/sync-kv/wrangler.toml | 14 ++ worker-sys/src/types/durable_object.rs | 2 + .../src/types/durable_object/storage.rs | 3 + .../types/durable_object/sync_kv_storage.rs | 23 ++++ worker/src/durable.rs | 16 +++ worker/src/lib.rs | 1 + worker/src/sync_kv.rs | 128 ++++++++++++++++++ 10 files changed, 267 insertions(+) create mode 100644 examples/sync-kv/Cargo.toml create mode 100644 examples/sync-kv/src/lib.rs create mode 100644 examples/sync-kv/wrangler.toml create mode 100644 worker-sys/src/types/durable_object/sync_kv_storage.rs create mode 100644 worker/src/sync_kv.rs diff --git a/Cargo.lock b/Cargo.lock index e91adf922..6d925b564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2584,6 +2584,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync-kv" +version = "0.1.0" +dependencies = [ + "serde_json", + "worker", + "worker-macros", +] + [[package]] name = "sync_wrapper" version = "1.0.2" diff --git a/examples/sync-kv/Cargo.toml b/examples/sync-kv/Cargo.toml new file mode 100644 index 000000000..937784930 --- /dev/null +++ b/examples/sync-kv/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sync-kv" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde_json = "^1" +worker.workspace = true +worker-macros.workspace = true diff --git a/examples/sync-kv/src/lib.rs b/examples/sync-kv/src/lib.rs new file mode 100644 index 000000000..6663deb4f --- /dev/null +++ b/examples/sync-kv/src/lib.rs @@ -0,0 +1,59 @@ +use serde_json::{Value, json}; +use worker::*; + +#[durable_object] +pub struct Test { + state: State, +} + +impl DurableObject for Test { + fn new(state: State, _env: Env) -> Self { + Self { state } + } + + async fn fetch(&self, _req: Request) -> Result { + let kv = self.state.storage().kv(); + + // CLEAN + kv.delete("a"); + kv.delete("b"); + + // CREATE + kv.put("a", json!({ "x": 1 }))?; + kv.put("b", json!({ "x": 2 }))?; + + // READ + let a: Option = kv.get("a")?; + let b: Option = kv.get("b")?; + let missing: Option = kv.get("c")?; + + // UPDATE + kv.put("a", json!({ "x": 42 }))?; + let a_updated: Option = kv.get("a")?; + + // DELETE + let deleted = kv.delete("b"); + let after_delete: Option = kv.get("b")?; + + // LIST + let mut list = Vec::new(); + for item in kv.list::() { + let (k, v) = item?; + list.push((k, v)); + } + + Response::from_json(&json!({ + "read": { "a": a, "b": b, "missing": missing }, + "update": a_updated, + "delete": { "deleted": deleted, "after_delete": after_delete }, + "list": list + })) + } +} + +#[event(fetch)] +async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { + let durable_obj = env.durable_object("TEST")?; + let stub = durable_obj.id_from_name("A")?.get_stub()?; + stub.fetch_with_request(req).await +} diff --git a/examples/sync-kv/wrangler.toml b/examples/sync-kv/wrangler.toml new file mode 100644 index 000000000..d52c7c90e --- /dev/null +++ b/examples/sync-kv/wrangler.toml @@ -0,0 +1,14 @@ +name = "worker-rs-test" +main = "build/index.js" +compatibility_date = "2026-04-19" + +[build] +command = "cargo install \"worker-build@^0.8\" && worker-build --release" + +[[durable_objects.bindings]] +name = "TEST" +class_name = "Test" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Test"] diff --git a/worker-sys/src/types/durable_object.rs b/worker-sys/src/types/durable_object.rs index c1d3b2704..c67bb8edc 100644 --- a/worker-sys/src/types/durable_object.rs +++ b/worker-sys/src/types/durable_object.rs @@ -6,6 +6,7 @@ mod namespace; mod sql_storage; mod state; mod storage; +mod sync_kv_storage; mod transaction; pub use container::*; @@ -14,6 +15,7 @@ pub use namespace::*; pub use sql_storage::*; pub use state::*; pub use storage::*; +pub use sync_kv_storage::*; pub use transaction::*; #[wasm_bindgen] diff --git a/worker-sys/src/types/durable_object/storage.rs b/worker-sys/src/types/durable_object/storage.rs index 08dadaa97..77175abad 100644 --- a/worker-sys/src/types/durable_object/storage.rs +++ b/worker-sys/src/types/durable_object/storage.rs @@ -77,4 +77,7 @@ extern "C" { #[wasm_bindgen(method, getter)] pub fn sql(this: &DurableObjectStorage) -> crate::types::SqlStorage; + + #[wasm_bindgen(method, getter)] + pub fn kv(this: &DurableObjectStorage) -> crate::types::SyncKvStorage; } diff --git a/worker-sys/src/types/durable_object/sync_kv_storage.rs b/worker-sys/src/types/durable_object/sync_kv_storage.rs new file mode 100644 index 000000000..60ca5bb48 --- /dev/null +++ b/worker-sys/src/types/durable_object/sync_kv_storage.rs @@ -0,0 +1,23 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = js_sys::Object)] + #[derive(Clone, Debug)] + pub type SyncKvStorage; + + #[wasm_bindgen(method)] + pub fn get(this: &SyncKvStorage, key: &str) -> JsValue; + + #[wasm_bindgen(method)] + pub fn put(this: &SyncKvStorage, key: &str, value: JsValue); + + #[wasm_bindgen(method)] + pub fn delete(this: &SyncKvStorage, key: &str) -> bool; + + #[wasm_bindgen(method)] + pub fn list(this: &SyncKvStorage) -> js_sys::Object; + + #[wasm_bindgen(method, js_name = list)] + pub fn list_with_options(this: &SyncKvStorage, options: js_sys::Object) -> js_sys::Object; +} diff --git a/worker/src/durable.rs b/worker/src/durable.rs index 446866b49..149c17cb7 100644 --- a/worker/src/durable.rs +++ b/worker/src/durable.rs @@ -569,6 +569,11 @@ impl Storage { pub fn sql(&self) -> crate::sql::SqlStorage { crate::sql::SqlStorage::new(self.inner.sql()) } + + // Add new method to access Synchronous KV APIs + pub fn kv(&self) -> crate::sync_kv::SyncKvStorage { + crate::sync_kv::SyncKvStorage::new(self.inner.kv()) + } } #[derive(Debug)] @@ -679,6 +684,10 @@ pub struct ListOptions<'a> { /// Key at which the list results should start, inclusive. #[serde(skip_serializing_if = "Option::is_none")] start: Option<&'a str>, + /// Key at which the list results should start, exclusive. + /// Cannot be used simultaneously with start. + #[serde(rename = "startAfter", skip_serializing_if = "Option::is_none")] + pub start_after: Option<&'a str>, /// Key at which the list results should end, exclusive. #[serde(skip_serializing_if = "Option::is_none")] end: Option<&'a str>, @@ -706,6 +715,13 @@ impl<'a> ListOptions<'a> { self } + /// Key at which the list results should start, exclusive. + /// Cannot be used simultaneously with start. + pub fn start_after(mut self, val: &'a str) -> Self { + self.start_after = Some(val); + self + } + /// Key at which the list results should end, exclusive. pub fn end(mut self, val: &'a str) -> Self { self.end = Some(val); diff --git a/worker/src/lib.rs b/worker/src/lib.rs index bbaead37c..e0426c2da 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -241,6 +241,7 @@ mod sql; mod streams; mod version; mod websocket; +mod sync_kv; /// A `Result` alias defaulting to [`Error`]. pub type Result = StdResult; diff --git a/worker/src/sync_kv.rs b/worker/src/sync_kv.rs new file mode 100644 index 000000000..6774d7140 --- /dev/null +++ b/worker/src/sync_kv.rs @@ -0,0 +1,128 @@ +use core::fmt; +use std::marker::PhantomData; + +use serde::{Serialize, de::DeserializeOwned}; +use serde_wasm_bindgen as swb; +use wasm_bindgen::JsCast as _; + +use crate::{Error, ListOptions, Result}; + +#[derive(Clone)] +pub struct SyncKvStorage { + inner: worker_sys::types::SyncKvStorage, +} + +unsafe impl Send for SyncKvStorage {} +unsafe impl Sync for SyncKvStorage {} + +impl SyncKvStorage { + pub(crate) fn new(inner: worker_sys::types::SyncKvStorage) -> Self { + Self { inner } + } +} + +impl SyncKvStorage { + pub fn get(&self, key: &str) -> Result> + where + T: DeserializeOwned, + { + let val = self.inner.get(key); + + if val.is_undefined() { + Ok(None) + } else { + Ok(Some(swb::from_value(val)?)) + } + } + + pub fn put(&self, key: &str, value: T) -> Result<()> + where + T: Serialize, + { + let js = swb::to_value(&value)?; + self.inner.put(key, js); + Ok(()) + } + + pub fn delete(&self, key: &str) -> bool { + self.inner.delete(key) + } +} + +pub struct SyncKvIterator { + inner: js_sys::Object, + _phantom: PhantomData, +} + +impl Iterator for SyncKvIterator +where + T: DeserializeOwned, +{ + type Item = Result<(String, T)>; + + fn next(&mut self) -> Option { + let next = js_sys::Reflect::get(&self.inner, &"next".into()) + .ok()? + .dyn_into::() + .ok()?; + + let result = next.call0(&self.inner).ok()?; + + let done = js_sys::Reflect::get(&result, &"done".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + if done { + return None; + } + + let value = js_sys::Reflect::get(&result, &"value".into()) + .map_err(Error::from) + .and_then(|v| { + let arr = js_sys::Array::from(&v); + + let key = arr.get(0).as_string().unwrap_or_default(); + let val = swb::from_value(arr.get(1))?; + + Ok((key, val)) + }); + + Some(value) + } +} + +impl SyncKvStorage { + pub fn list(&self) -> SyncKvIterator + where + T: DeserializeOwned, + { + SyncKvIterator { + inner: self.inner.list(), + _phantom: PhantomData, + } + } + + pub fn list_with_options(&self, options: ListOptions<'_>) -> Result> { + let js_opts = swb::to_value(&options)?; + + let iter = self.inner.list_with_options(js_opts.into()); + + Ok(SyncKvIterator { + inner: iter, + _phantom: PhantomData, + }) + } +} + +impl fmt::Debug for SyncKvStorage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SyncKvStorage").finish() + } +} + +impl fmt::Debug for SyncKvIterator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SyncKvIterator").finish() + } +} From 470428ad5aa9443208b0c4a7426a35f8d2b7cd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:42:03 +0800 Subject: [PATCH 2/8] chore: add tests and change `serde_json` to workspace --- examples/sync-kv/Cargo.toml | 2 +- test/src/lib.rs | 1 + test/src/router.rs | 4 ++- test/src/synchronous_storage.rs | 48 +++++++++++++++++++++++++++++++++ test/wrangler.toml | 10 +++++-- 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 test/src/synchronous_storage.rs diff --git a/examples/sync-kv/Cargo.toml b/examples/sync-kv/Cargo.toml index 937784930..0f808e4c7 100644 --- a/examples/sync-kv/Cargo.toml +++ b/examples/sync-kv/Cargo.toml @@ -7,6 +7,6 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -serde_json = "^1" +serde_json.workspace = true worker.workspace = true worker-macros.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index 6fb94a33c..7aea2e930 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -36,6 +36,7 @@ mod service; mod socket; mod sql_counter; mod sql_iterator; +mod synchronous_storage; mod user; mod ws; diff --git a/test/src/router.rs b/test/src/router.rs index b6f348717..bd07b33a8 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -1,7 +1,8 @@ use crate::{ alarm, analytics_engine, assets, auto_response, cache, container, counter, d1, durable, fetch, form, js_snippets, kv, put_raw, queue, r2, rate_limit, request, secret_store, service, socket, - sql_counter, sql_iterator, user, ws, SomeSharedData, GLOBAL_SECOND_START, GLOBAL_STATE, + sql_counter, sql_iterator, synchronous_storage, user, ws, SomeSharedData, GLOBAL_SECOND_START, + GLOBAL_STATE, }; #[cfg(feature = "http")] use std::convert::TryInto; @@ -239,6 +240,7 @@ macro_rules! add_routes ( add_route!($obj, get, format_route!("/rate-limit/key/{}", "key"), rate_limit::handle_rate_limit_with_key); add_route!($obj, get, "/rate-limit/bulk-test", rate_limit::handle_rate_limit_bulk_test); add_route!($obj, get, "/rate-limit/reset", rate_limit::handle_rate_limit_reset); + add_route!($obj, get, "/synchronous-storage", synchronous_storage::handle_synchronous_storage); }); #[cfg(feature = "http")] diff --git a/test/src/synchronous_storage.rs b/test/src/synchronous_storage.rs new file mode 100644 index 000000000..1d587ad54 --- /dev/null +++ b/test/src/synchronous_storage.rs @@ -0,0 +1,48 @@ +use worker::*; + +#[durable_object] +pub struct SynchronousStorage { + state: State, +} + +impl DurableObject for SynchronousStorage { + fn new(state: State, _env: Env) -> Self { + Self { state } + } + + async fn fetch(&self, _req: Request) -> Result { + let sync_kv = self.state.storage().kv(); + + let first = serde_json::json!({"x": 1}); + let second = serde_json::json!({"x": 2}); + + sync_kv.put("first", first.clone())?; + sync_kv.put("second", second.clone())?; + + assert_eq!(sync_kv.get("first")?, Some(first.clone())); + assert_eq!(sync_kv.get("second")?, Some(second.clone())); + + let original = [first, second]; + + sync_kv.list().all(|e| { + let val: serde_json::Value = e.expect("sync_kv list").1; + original.contains(&val) + }); + + assert!(sync_kv.delete("first")); + assert!(sync_kv.delete("second")); + + Ok(Response::empty()?.with_status(204)) + } +} + +#[worker::send] +pub async fn handle_synchronous_storage( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.unique_id()?.get_stub()?; + stub.fetch_with_request(req).await +} \ No newline at end of file diff --git a/test/wrangler.toml b/test/wrangler.toml index 16b49d87e..f722f8176 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -1,6 +1,6 @@ name = "testing-rust-worker" workers_dev = true -compatibility_date = "2025-09-23" # required +compatibility_date = "2025-09-23" # required main = "build/worker/shim.mjs" kv_namespaces = [ @@ -31,6 +31,7 @@ bindings = [ { name = "SQL_ITERATOR", class_name = "SqlIterator" }, { name = "MY_CLASS", class_name = "MyClass" }, { name = "ECHO_CONTAINER", class_name = "EchoContainer" }, + { name = "SYNCHRONOUS_STORAGE", class_name = "SynchronousStorage" }, ] [[analytics_engine_datasets]] @@ -74,7 +75,12 @@ command = "WASM_BINDGEN_BIN=../wasm-bindgen/target/debug/wasm-bindgen ../target/ [[migrations]] tag = "v1" -new_sqlite_classes = ["SqlCounter", "SqlIterator", "EchoContainer"] +new_sqlite_classes = [ + "SqlCounter", + "SqlIterator", + "EchoContainer", + "SynchronousStorage", +] [[secrets_store_secrets]] binding = "SECRETS" From 6f12bf81f0a9f5236fad964ffe165839cf68e382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:52:39 +0800 Subject: [PATCH 3/8] chore: make test deterministic via sorting --- test/src/synchronous_storage.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/src/synchronous_storage.rs b/test/src/synchronous_storage.rs index 1d587ad54..9e494e918 100644 --- a/test/src/synchronous_storage.rs +++ b/test/src/synchronous_storage.rs @@ -22,12 +22,17 @@ impl DurableObject for SynchronousStorage { assert_eq!(sync_kv.get("first")?, Some(first.clone())); assert_eq!(sync_kv.get("second")?, Some(second.clone())); - let original = [first, second]; + let mut original = [ + (String::from("first"), first), + (String::from("second"), second), + ]; + let mut list: Box<[(String, serde_json::Value)]> = + sync_kv.list().filter_map(Result::ok).collect(); - sync_kv.list().all(|e| { - let val: serde_json::Value = e.expect("sync_kv list").1; - original.contains(&val) - }); + original.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + list.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(original.as_slice(), list.as_ref()); assert!(sync_kv.delete("first")); assert!(sync_kv.delete("second")); From e596ea7fc85253e6f4f64d11a269409ec7dbd10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:55:54 +0800 Subject: [PATCH 4/8] chore: using `js_sys::try_iter` instread for correctness - more error handling at the `Iterator` impl --- test/src/synchronous_storage.rs | 3 +- worker/src/sync_kv.rs | 71 +++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/test/src/synchronous_storage.rs b/test/src/synchronous_storage.rs index 9e494e918..5b7e3cf0d 100644 --- a/test/src/synchronous_storage.rs +++ b/test/src/synchronous_storage.rs @@ -26,8 +26,7 @@ impl DurableObject for SynchronousStorage { (String::from("first"), first), (String::from("second"), second), ]; - let mut list: Box<[(String, serde_json::Value)]> = - sync_kv.list().filter_map(Result::ok).collect(); + let mut list: Box<[_]> = sync_kv.list()?.filter_map(Result::ok).collect(); original.sort_unstable_by(|a, b| a.0.cmp(&b.0)); list.sort_unstable_by(|a, b| a.0.cmp(&b.0)); diff --git a/worker/src/sync_kv.rs b/worker/src/sync_kv.rs index 6774d7140..4b908f558 100644 --- a/worker/src/sync_kv.rs +++ b/worker/src/sync_kv.rs @@ -4,19 +4,20 @@ use std::marker::PhantomData; use serde::{Serialize, de::DeserializeOwned}; use serde_wasm_bindgen as swb; use wasm_bindgen::JsCast as _; +use worker_sys::types::SyncKvStorage as SyncKvStorageSys; use crate::{Error, ListOptions, Result}; #[derive(Clone)] pub struct SyncKvStorage { - inner: worker_sys::types::SyncKvStorage, + inner: SyncKvStorageSys, } unsafe impl Send for SyncKvStorage {} unsafe impl Sync for SyncKvStorage {} impl SyncKvStorage { - pub(crate) fn new(inner: worker_sys::types::SyncKvStorage) -> Self { + pub(crate) fn new(inner: SyncKvStorageSys) -> Self { Self { inner } } } @@ -50,7 +51,7 @@ impl SyncKvStorage { } pub struct SyncKvIterator { - inner: js_sys::Object, + inner: js_sys::IntoIter, _phantom: PhantomData, } @@ -61,46 +62,53 @@ where type Item = Result<(String, T)>; fn next(&mut self) -> Option { - let next = js_sys::Reflect::get(&self.inner, &"next".into()) - .ok()? - .dyn_into::() - .ok()?; + let result = match self.inner.next()? { + Ok(r) => r, + Err(e) => return Some(Err(Error::from(e))), + }; - let result = next.call0(&self.inner).ok()?; + if !js_sys::Array::is_array(&result) { + return Some(Err(Error::JsError("Expected result to be array".into()))); + } - let done = js_sys::Reflect::get(&result, &"done".into()) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(true); + let arr: js_sys::Array = result.unchecked_into(); - if done { - return None; + if arr.length() < 2 { + return Some(Err(Error::JsError( + "Expected entry to have at least 2 elements".into(), + ))); } - let value = js_sys::Reflect::get(&result, &"value".into()) - .map_err(Error::from) - .and_then(|v| { - let arr = js_sys::Array::from(&v); - - let key = arr.get(0).as_string().unwrap_or_default(); - let val = swb::from_value(arr.get(1))?; + let key = match arr.get(0).as_string() { + Some(k) => k, + None => { + return Some(Err(Error::JsError("Expected key to be string".into()))); + } + }; - Ok((key, val)) - }); + let val = match swb::from_value(arr.get(1)) { + Ok(v) => v, + Err(e) => return Some(Err(Error::from(e))), + }; - Some(value) + Some(Ok((key, val))) } } impl SyncKvStorage { - pub fn list(&self) -> SyncKvIterator + const ERR_NOT_AN_ITERABLE: &str = "SyncKvStorage.list() did not return an iterable"; + + pub fn list(&self) -> Result> where T: DeserializeOwned, { - SyncKvIterator { - inner: self.inner.list(), + let inner = js_sys::try_iter(&self.inner.list())? + .ok_or_else(|| Error::JsError(Self::ERR_NOT_AN_ITERABLE.into()))?; + + Ok(SyncKvIterator { + inner, _phantom: PhantomData, - } + }) } pub fn list_with_options(&self, options: ListOptions<'_>) -> Result> { @@ -108,8 +116,11 @@ impl SyncKvStorage { let iter = self.inner.list_with_options(js_opts.into()); + let inner = js_sys::try_iter(&iter)? + .ok_or_else(|| Error::JsError(Self::ERR_NOT_AN_ITERABLE.into()))?; + Ok(SyncKvIterator { - inner: iter, + inner, _phantom: PhantomData, }) } @@ -125,4 +136,4 @@ impl fmt::Debug for SyncKvIterator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SyncKvIterator").finish() } -} +} \ No newline at end of file From b625d655d385754ff874201d724d06da5dcbe70e Mon Sep 17 00:00:00 2001 From: AsenHu <78863300+AsenHu@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:46:31 +0800 Subject: [PATCH 5/8] chore: add docs and fixed a compile error --- examples/sync-kv/src/lib.rs | 4 ++-- worker/src/durable.rs | 6 ++++-- worker/src/lib.rs | 1 + worker/src/sync_kv.rs | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/examples/sync-kv/src/lib.rs b/examples/sync-kv/src/lib.rs index 6663deb4f..5e49998a6 100644 --- a/examples/sync-kv/src/lib.rs +++ b/examples/sync-kv/src/lib.rs @@ -1,4 +1,4 @@ -use serde_json::{Value, json}; +use serde_json::{json, Value}; use worker::*; #[durable_object] @@ -37,7 +37,7 @@ impl DurableObject for Test { // LIST let mut list = Vec::new(); - for item in kv.list::() { + for item in kv.list::()? { let (k, v) = item?; list.push((k, v)); } diff --git a/worker/src/durable.rs b/worker/src/durable.rs index 149c17cb7..14f530a0f 100644 --- a/worker/src/durable.rs +++ b/worker/src/durable.rs @@ -565,12 +565,14 @@ impl Storage { .map(|_| ()) } - // Add new method to access SQLite APIs + /// Access the SQLite APIs exposed at `ctx.storage.sql`. pub fn sql(&self) -> crate::sql::SqlStorage { crate::sql::SqlStorage::new(self.inner.sql()) } - // Add new method to access Synchronous KV APIs + /// Access the synchronous key-value APIs exposed at `ctx.storage.kv`. + /// + /// This is Cloudflare's Synchronous KV API for SQLite-backed Durable Objects pub fn kv(&self) -> crate::sync_kv::SyncKvStorage { crate::sync_kv::SyncKvStorage::new(self.inner.kv()) } diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e0426c2da..e29e8220a 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -278,3 +278,4 @@ pub type HttpRequest = ::http::Request; pub type HttpResponse = ::http::Response; pub use crate::sql::*; +pub use crate::sync_kv::*; diff --git a/worker/src/sync_kv.rs b/worker/src/sync_kv.rs index 4b908f558..ac2b420fc 100644 --- a/worker/src/sync_kv.rs +++ b/worker/src/sync_kv.rs @@ -1,13 +1,24 @@ +//! Bindings for Cloudflare Durable Objects' Synchronous KV API exposed via +//! [`Storage::kv`](crate::Storage::kv). +//! +//! This is the `ctx.storage.kv` API available on SQLite-backed Durable Objects. +//! Entries are stored in the Durable Object's hidden `__cf_kv` SQLite table. +//! Values are converted with [`serde_wasm_bindgen`], allowing typed access through `serde`. + use core::fmt; use std::marker::PhantomData; -use serde::{Serialize, de::DeserializeOwned}; +use serde::{de::DeserializeOwned, Serialize}; use serde_wasm_bindgen as swb; use wasm_bindgen::JsCast as _; use worker_sys::types::SyncKvStorage as SyncKvStorageSys; use crate::{Error, ListOptions, Result}; +/// Cloudflare Durable Objects' Synchronous KV API exposed by [`Storage::kv`](crate::Storage::kv). +/// +/// This is the `ctx.storage.kv` interface for SQLite-backed Durable Objects. +/// Values are serialized with [`Serialize`] and deserialized with [`DeserializeOwned`]. #[derive(Clone)] pub struct SyncKvStorage { inner: SyncKvStorageSys, @@ -23,6 +34,9 @@ impl SyncKvStorage { } impl SyncKvStorage { + /// Retrieves the value associated with the given key. + /// + /// Returns `Ok(None)` if the key does not exist. pub fn get(&self, key: &str) -> Result> where T: DeserializeOwned, @@ -36,6 +50,7 @@ impl SyncKvStorage { } } + /// Stores the value and associates it with the given key. pub fn put(&self, key: &str, value: T) -> Result<()> where T: Serialize, @@ -45,11 +60,18 @@ impl SyncKvStorage { Ok(()) } + /// Deletes the key and associated value. + /// + /// Returns `true` if the key existed and was removed, or `false` if it did not exist. pub fn delete(&self, key: &str) -> bool { self.inner.delete(key) } } +/// Iterator over typed entries returned by [`SyncKvStorage::list`] and +/// [`SyncKvStorage::list_with_options`]. +/// +/// Each item yields the key together with its deserialized value. pub struct SyncKvIterator { inner: js_sys::IntoIter, _phantom: PhantomData, @@ -98,6 +120,9 @@ where impl SyncKvStorage { const ERR_NOT_AN_ITERABLE: &str = "SyncKvStorage.list() did not return an iterable"; + /// Returns an iterator over all key-value pairs in ascending lexicographic order. + /// + /// Each iterator item contains the key and a value deserialized as `T`. pub fn list(&self) -> Result> where T: DeserializeOwned, @@ -111,6 +136,9 @@ impl SyncKvStorage { }) } + /// Returns an iterator over key-value pairs that match the provided [`ListOptions`]. + /// + /// Each iterator item contains the key and a value deserialized as `T`. pub fn list_with_options(&self, options: ListOptions<'_>) -> Result> { let js_opts = swb::to_value(&options)?; @@ -136,4 +164,4 @@ impl fmt::Debug for SyncKvIterator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SyncKvIterator").finish() } -} \ No newline at end of file +} From 4f59a5fd3a646b28cc92c32c90183e719893779a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:20:22 +0800 Subject: [PATCH 6/8] test: wire up synchronous storage DO test in vitest --- test/tests/mf.ts | 4 ++++ test/tests/synchronous_storage.spec.ts | 9 +++++++++ 2 files changed, 13 insertions(+) create mode 100644 test/tests/synchronous_storage.spec.ts diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 3c881b187..18cffe0cb 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -75,6 +75,10 @@ const mf_instance = new Miniflare({ className: "SqlIterator", useSQLite: true, }, + SYNCHRONOUS_STORAGE: { + className: "SynchronousStorage", + useSQLite: true + }, }, kvNamespaces: ["SOME_NAMESPACE", "FILE_SIZES", "TEST"], serviceBindings: { diff --git a/test/tests/synchronous_storage.spec.ts b/test/tests/synchronous_storage.spec.ts new file mode 100644 index 000000000..9c3687209 --- /dev/null +++ b/test/tests/synchronous_storage.spec.ts @@ -0,0 +1,9 @@ +import {describe, test, expect} from "vitest"; +import { mf, mfUrl } from "./mf"; + +describe("synchronous api durable object", () => { + test("synchronous-storage", async () => { + const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage`); + expect(resp.status).toBe(204); + }); +}); From a205f984fd9b2518ed0ed08d6fc872d7407e3e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=81=E3=82=BB?= <123655015+chise0713@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:06:47 +0800 Subject: [PATCH 7/8] test: expand synchronous storage DO coverage --- test/src/router.rs | 8 +- test/src/synchronous_storage.rs | 189 ++++++++++++++++++++++--- test/tests/synchronous_storage.spec.ts | 55 ++++++- 3 files changed, 225 insertions(+), 27 deletions(-) diff --git a/test/src/router.rs b/test/src/router.rs index bd07b33a8..3cbac6664 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -240,7 +240,13 @@ macro_rules! add_routes ( add_route!($obj, get, format_route!("/rate-limit/key/{}", "key"), rate_limit::handle_rate_limit_with_key); add_route!($obj, get, "/rate-limit/bulk-test", rate_limit::handle_rate_limit_bulk_test); add_route!($obj, get, "/rate-limit/reset", rate_limit::handle_rate_limit_reset); - add_route!($obj, get, "/synchronous-storage", synchronous_storage::handle_synchronous_storage); + add_route!($obj, get, "/synchronous-storage/smoke", synchronous_storage::handle_synchronous_storage_smoke); + add_route!($obj, get, "/synchronous-storage/overwrite", synchronous_storage::handle_synchronous_storage_overwrite); + add_route!($obj, get, "/synchronous-storage/not_found", synchronous_storage::handle_synchronous_storage_not_found); + add_route!($obj, get, "/synchronous-storage/list", synchronous_storage::handle_synchronous_storage_list); + add_route!($obj, get, "/synchronous-storage/persist_fill", synchronous_storage::handle_synchronous_storage_persist_fill); + add_route!($obj, get, "/synchronous-storage/persist_check", synchronous_storage::handle_synchronous_storage_persist_check); + add_route!($obj, get, "/synchronous-storage/persist_cleanup", synchronous_storage::handle_synchronous_storage_persist_cleanup); }); #[cfg(feature = "http")] diff --git a/test/src/synchronous_storage.rs b/test/src/synchronous_storage.rs index 5b7e3cf0d..1b8b5be25 100644 --- a/test/src/synchronous_storage.rs +++ b/test/src/synchronous_storage.rs @@ -10,43 +10,190 @@ impl DurableObject for SynchronousStorage { Self { state } } - async fn fetch(&self, _req: Request) -> Result { + async fn fetch(&self, req: Request) -> Result { + const KEYS_LEN: usize = 10; + let sync_kv = self.state.storage().kv(); + let path = req.path(); + + match path.as_str() { + "/smoke" => { + let first = serde_json::json!({"x": 1}); + let second = serde_json::json!({"x": 2}); + + sync_kv.put("first", first.clone())?; + sync_kv.put("second", second.clone())?; + + assert_eq!(sync_kv.get("first")?, Some(first.clone())); + assert_eq!(sync_kv.get("second")?, Some(second.clone())); + + let mut original = [ + (String::from("first"), first), + (String::from("second"), second), + ]; + let mut list: Box<[_]> = sync_kv.list()?.map(Result::unwrap).collect(); + + original.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + list.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(original.as_slice(), list.as_ref()); + + assert!(sync_kv.delete("first")); + assert!(sync_kv.delete("second")); + + Response::ok("smoke ok") + } + "/overwrite" => { + let overwrite = serde_json::json!({"v": 2}); + + sync_kv.put("k", serde_json::json!({"v": 1}))?; + sync_kv.put("k", overwrite.clone())?; + + assert_eq!(sync_kv.get("k")?, Some(overwrite)); + + assert!(sync_kv.delete("k")); + + Response::ok("overwrite ok") + } + "/not_found" => { + assert_eq!(sync_kv.get::<()>("nope")?, None); + assert!(!sync_kv.delete("nope")); + + Response::ok("not_found ok") + } + "/list" => { + let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); + + for (i, key) in keys.iter().enumerate() { + sync_kv.put(key, serde_json::json!({ "i": i }))?; + } + + let count = { + let list: SyncKvIterator = sync_kv.list()?; + list.count() + }; + + assert_eq!(count, KEYS_LEN); - let first = serde_json::json!({"x": 1}); - let second = serde_json::json!({"x": 2}); + for key in keys { + assert!(sync_kv.delete(&key)); + } - sync_kv.put("first", first.clone())?; - sync_kv.put("second", second.clone())?; + Response::ok("list ok") + } + "/persist_fill" => { + let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); - assert_eq!(sync_kv.get("first")?, Some(first.clone())); - assert_eq!(sync_kv.get("second")?, Some(second.clone())); + for (i, key) in keys.iter().enumerate() { + sync_kv.put(key, serde_json::json!({ "i": i }))?; + } - let mut original = [ - (String::from("first"), first), - (String::from("second"), second), - ]; - let mut list: Box<[_]> = sync_kv.list()?.filter_map(Result::ok).collect(); + Response::ok("persist_fill ok") + } + "/persist_check" => { + let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); - original.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - list.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + for (i, key) in keys.iter().enumerate() { + let val: Option = sync_kv.get(key)?; - assert_eq!(original.as_slice(), list.as_ref()); + assert!(val.is_some()); - assert!(sync_kv.delete("first")); - assert!(sync_kv.delete("second")); + assert_eq!(val, Some(serde_json::json!({"i": i}))); + } - Ok(Response::empty()?.with_status(204)) + Response::ok("persist_check ok") + } + "/persist_cleanup" => { + let list: SyncKvIterator = sync_kv.list()?; + + let keys_collected: Box<[_]> = + list.filter_map(|e| e.ok().map(|(k, _)| k)).collect(); + + for key in keys_collected { + assert!(sync_kv.delete(&key)); + } + + Response::ok("persist_cleanup ok") + } + _ => Response::error("unknown test", 404), + } } } #[worker::send] -pub async fn handle_synchronous_storage( - req: Request, +pub async fn handle_synchronous_storage_smoke( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.unique_id()?.get_stub()?; + stub.fetch_with_str("http://fake-host/smoke").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_overwrite( + _req: Request, env: Env, _data: crate::SomeSharedData, ) -> Result { let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; let stub = namespace.unique_id()?.get_stub()?; - stub.fetch_with_request(req).await + stub.fetch_with_str("http://fake-host/overwrite").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_not_found( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.unique_id()?.get_stub()?; + stub.fetch_with_str("http://fake-host/not_found").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_list( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.unique_id()?.get_stub()?; + stub.fetch_with_str("http://fake-host/list").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_persist_fill( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.id_from_name("singleton")?.get_stub()?; + stub.fetch_with_str("http://fake-host/persist_fill").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_persist_check( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.id_from_name("singleton")?.get_stub()?; + stub.fetch_with_str("http://fake-host/persist_check").await +} + +#[worker::send] +pub async fn handle_synchronous_storage_persist_cleanup( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.id_from_name("singleton")?.get_stub()?; + stub.fetch_with_str("http://fake-host/persist_cleanup") + .await } \ No newline at end of file diff --git a/test/tests/synchronous_storage.spec.ts b/test/tests/synchronous_storage.spec.ts index 9c3687209..636dee746 100644 --- a/test/tests/synchronous_storage.spec.ts +++ b/test/tests/synchronous_storage.spec.ts @@ -1,9 +1,54 @@ -import {describe, test, expect} from "vitest"; +import { describe, test, expect } from "vitest"; import { mf, mfUrl } from "./mf"; describe("synchronous api durable object", () => { - test("synchronous-storage", async () => { - const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage`); - expect(resp.status).toBe(204); + test("smoke", async () => { + const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage/smoke`); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("smoke ok"); }); -}); + + test("overwrite", async () => { + const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage/overwrite`); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("overwrite ok"); + }); + + test("not_found", async () => { + const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage/not_found`); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("not_found ok"); + }); + + test("list", async () => { + const resp = await mf.dispatchFetch(`${mfUrl}synchronous-storage/list`); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("list ok"); + }); + + describe.sequential("persist", () => { + test("fill", async () => { + const resp = await mf.dispatchFetch( + `${mfUrl}synchronous-storage/persist_fill` + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("persist_fill ok"); + }); + + test("check", async () => { + const resp = await mf.dispatchFetch( + `${mfUrl}synchronous-storage/persist_check` + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("persist_check ok"); + }); + + test("cleanup", async () => { + const resp = await mf.dispatchFetch( + `${mfUrl}synchronous-storage/persist_cleanup` + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("persist_cleanup ok"); + }); + }); +}); \ No newline at end of file From 1937aef23921791fe984a93afd270cda80b9327c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 28 Apr 2026 17:07:20 -0700 Subject: [PATCH 8/8] refactor(sync-kv): tighten bindings and align with worker-sys conventions Address review feedback on the SyncKvStorage bindings and bring them in line with the patterns used elsewhere in worker-sys. worker-sys: - SyncKvStorage methods now have both non-throwing and `try_*` (`catch`) variants, matching the convention used for other Durable Object bindings - `extends = Object` with `#[derive(Debug, Clone, PartialEq, Eq)]` - `put(value: &JsValue)` takes by reference; `list()` returns `JsValue` since `Iterable<[string, T]>` has no wasm-bindgen mapping - New `SyncKvListOptions` extern type with getters/setters for every field, exposing the JS object directly rather than serialising a Rust struct worker: - High-level `SyncKvStorage` calls the `try_*` sys variants so JS exceptions surface as `Error` instead of aborting - `delete` now returns `Result` to match the fallible JS call - `SyncKvListOptions` is re-exported from `worker-sys` directly; construction is via `SyncKvListOptionsBuilder::new()` which mutates the underlying JS object in place \u2014 no Rust-owned intermediate representation - Reverted `startAfter` from `durable::ListOptions`; that field belongs to the async storage API and should land separately - Tightened `SyncKvIterator::next` to use `dyn_into::` instead of dual checks; alphabetised `mod` ordering in lib.rs example: - Renamed wrangler worker name from `worker-rs-test` to `sync-kv-example` to avoid colliding with the test crate's deployed worker name - Aligned `compatibility_date` with the test crate's (`2025-09-23`) - Renamed `Test` durable object to `SyncKvDurableObject` for clarity tests: - Added `/list_options` exercising `start`, `start_after`, `end`, `reverse`, and `limit` against real workerd behaviour - Updated existing assertions for the new `Result` shape of `delete` - Use `collect::>>()?` instead of `.unwrap()` so test failures surface useful errors - Moved `KEYS_LEN` const into the arms that use it - Added missing trailing newlines on new files --- examples/sync-kv/src/lib.rs | 14 +- examples/sync-kv/wrangler.toml | 10 +- test/src/router.rs | 1 + test/src/synchronous_storage.rs | 89 +++++++- test/tests/synchronous_storage.spec.ts | 10 +- test/wrangler.toml | 2 +- .../types/durable_object/sync_kv_storage.rs | 93 +++++++- worker/src/durable.rs | 13 +- worker/src/lib.rs | 2 +- worker/src/sync_kv.rs | 208 +++++++++++------- 10 files changed, 326 insertions(+), 116 deletions(-) diff --git a/examples/sync-kv/src/lib.rs b/examples/sync-kv/src/lib.rs index 5e49998a6..9cb4c48fa 100644 --- a/examples/sync-kv/src/lib.rs +++ b/examples/sync-kv/src/lib.rs @@ -2,11 +2,11 @@ use serde_json::{json, Value}; use worker::*; #[durable_object] -pub struct Test { +pub struct SyncKvDurableObject { state: State, } -impl DurableObject for Test { +impl DurableObject for SyncKvDurableObject { fn new(state: State, _env: Env) -> Self { Self { state } } @@ -15,8 +15,8 @@ impl DurableObject for Test { let kv = self.state.storage().kv(); // CLEAN - kv.delete("a"); - kv.delete("b"); + kv.delete("a")?; + kv.delete("b")?; // CREATE kv.put("a", json!({ "x": 1 }))?; @@ -32,7 +32,7 @@ impl DurableObject for Test { let a_updated: Option = kv.get("a")?; // DELETE - let deleted = kv.delete("b"); + let deleted = kv.delete("b")?; let after_delete: Option = kv.get("b")?; // LIST @@ -53,7 +53,7 @@ impl DurableObject for Test { #[event(fetch)] async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { - let durable_obj = env.durable_object("TEST")?; - let stub = durable_obj.id_from_name("A")?.get_stub()?; + let durable_obj = env.durable_object("SYNC_KV")?; + let stub = durable_obj.id_from_name("singleton")?.get_stub()?; stub.fetch_with_request(req).await } diff --git a/examples/sync-kv/wrangler.toml b/examples/sync-kv/wrangler.toml index d52c7c90e..6ffd23d27 100644 --- a/examples/sync-kv/wrangler.toml +++ b/examples/sync-kv/wrangler.toml @@ -1,14 +1,14 @@ -name = "worker-rs-test" +name = "sync-kv-example" main = "build/index.js" -compatibility_date = "2026-04-19" +compatibility_date = "2025-09-23" [build] command = "cargo install \"worker-build@^0.8\" && worker-build --release" [[durable_objects.bindings]] -name = "TEST" -class_name = "Test" +name = "SYNC_KV" +class_name = "SyncKvDurableObject" [[migrations]] tag = "v1" -new_sqlite_classes = ["Test"] +new_sqlite_classes = ["SyncKvDurableObject"] diff --git a/test/src/router.rs b/test/src/router.rs index 23d31320d..c52fae585 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -245,6 +245,7 @@ macro_rules! add_routes ( add_route!($obj, get, "/synchronous-storage/overwrite", synchronous_storage::handle_synchronous_storage_overwrite); add_route!($obj, get, "/synchronous-storage/not_found", synchronous_storage::handle_synchronous_storage_not_found); add_route!($obj, get, "/synchronous-storage/list", synchronous_storage::handle_synchronous_storage_list); + add_route!($obj, get, "/synchronous-storage/list_options", synchronous_storage::handle_synchronous_storage_list_options); add_route!($obj, get, "/synchronous-storage/persist_fill", synchronous_storage::handle_synchronous_storage_persist_fill); add_route!($obj, get, "/synchronous-storage/persist_check", synchronous_storage::handle_synchronous_storage_persist_check); add_route!($obj, get, "/synchronous-storage/persist_cleanup", synchronous_storage::handle_synchronous_storage_persist_cleanup); diff --git a/test/src/synchronous_storage.rs b/test/src/synchronous_storage.rs index 1b8b5be25..523fcf29e 100644 --- a/test/src/synchronous_storage.rs +++ b/test/src/synchronous_storage.rs @@ -11,8 +11,6 @@ impl DurableObject for SynchronousStorage { } async fn fetch(&self, req: Request) -> Result { - const KEYS_LEN: usize = 10; - let sync_kv = self.state.storage().kv(); let path = req.path(); @@ -38,8 +36,8 @@ impl DurableObject for SynchronousStorage { assert_eq!(original.as_slice(), list.as_ref()); - assert!(sync_kv.delete("first")); - assert!(sync_kv.delete("second")); + assert!(sync_kv.delete("first")?); + assert!(sync_kv.delete("second")?); Response::ok("smoke ok") } @@ -51,17 +49,18 @@ impl DurableObject for SynchronousStorage { assert_eq!(sync_kv.get("k")?, Some(overwrite)); - assert!(sync_kv.delete("k")); + assert!(sync_kv.delete("k")?); Response::ok("overwrite ok") } "/not_found" => { assert_eq!(sync_kv.get::<()>("nope")?, None); - assert!(!sync_kv.delete("nope")); + assert!(!sync_kv.delete("nope")?); Response::ok("not_found ok") } "/list" => { + const KEYS_LEN: usize = 10; let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); for (i, key) in keys.iter().enumerate() { @@ -76,12 +75,72 @@ impl DurableObject for SynchronousStorage { assert_eq!(count, KEYS_LEN); for key in keys { - assert!(sync_kv.delete(&key)); + assert!(sync_kv.delete(&key)?); } Response::ok("list ok") } + "/list_options" => { + // Seed: a, b, c, d, e + for k in ["a", "b", "c", "d", "e"] { + sync_kv.put(k, serde_json::json!({"k": k}))?; + } + + // start_after("b") yields c, d, e + let after_b: Vec = sync_kv + .list_with_options::( + &SyncKvListOptionsBuilder::new().start_after("b").build(), + )? + .collect::>>()? + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(after_b, vec!["c", "d", "e"]); + + // start("b") yields b, c, d, e + let from_b: Vec = sync_kv + .list_with_options::( + &SyncKvListOptionsBuilder::new().start("b").build(), + )? + .collect::>>()? + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(from_b, vec!["b", "c", "d", "e"]); + + // limit(2) + reverse(true) yields e, d + let last_two: Vec = sync_kv + .list_with_options::( + &SyncKvListOptionsBuilder::new() + .reverse(true) + .limit(2) + .build(), + )? + .collect::>>()? + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(last_two, vec!["e", "d"]); + + // end("c") yields a, b (exclusive) + let until_c: Vec = sync_kv + .list_with_options::( + &SyncKvListOptionsBuilder::new().end("c").build(), + )? + .collect::>>()? + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(until_c, vec!["a", "b"]); + + for k in ["a", "b", "c", "d", "e"] { + assert!(sync_kv.delete(k)?); + } + + Response::ok("list_options ok") + } "/persist_fill" => { + const KEYS_LEN: usize = 10; let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); for (i, key) in keys.iter().enumerate() { @@ -91,6 +150,7 @@ impl DurableObject for SynchronousStorage { Response::ok("persist_fill ok") } "/persist_check" => { + const KEYS_LEN: usize = 10; let keys: [_; KEYS_LEN] = std::array::from_fn(|i| format!("k{i}")); for (i, key) in keys.iter().enumerate() { @@ -110,7 +170,7 @@ impl DurableObject for SynchronousStorage { list.filter_map(|e| e.ok().map(|(k, _)| k)).collect(); for key in keys_collected { - assert!(sync_kv.delete(&key)); + assert!(sync_kv.delete(&key)?); } Response::ok("persist_cleanup ok") @@ -164,6 +224,17 @@ pub async fn handle_synchronous_storage_list( stub.fetch_with_str("http://fake-host/list").await } +#[worker::send] +pub async fn handle_synchronous_storage_list_options( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let namespace = env.durable_object("SYNCHRONOUS_STORAGE")?; + let stub = namespace.unique_id()?.get_stub()?; + stub.fetch_with_str("http://fake-host/list_options").await +} + #[worker::send] pub async fn handle_synchronous_storage_persist_fill( _req: Request, @@ -196,4 +267,4 @@ pub async fn handle_synchronous_storage_persist_cleanup( let stub = namespace.id_from_name("singleton")?.get_stub()?; stub.fetch_with_str("http://fake-host/persist_cleanup") .await -} \ No newline at end of file +} diff --git a/test/tests/synchronous_storage.spec.ts b/test/tests/synchronous_storage.spec.ts index 636dee746..1ada2832b 100644 --- a/test/tests/synchronous_storage.spec.ts +++ b/test/tests/synchronous_storage.spec.ts @@ -26,6 +26,14 @@ describe("synchronous api durable object", () => { expect(await resp.text()).toBe("list ok"); }); + test("list_options", async () => { + const resp = await mf.dispatchFetch( + `${mfUrl}synchronous-storage/list_options` + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("list_options ok"); + }); + describe.sequential("persist", () => { test("fill", async () => { const resp = await mf.dispatchFetch( @@ -51,4 +59,4 @@ describe("synchronous api durable object", () => { expect(await resp.text()).toBe("persist_cleanup ok"); }); }); -}); \ No newline at end of file +}); diff --git a/test/wrangler.toml b/test/wrangler.toml index f722f8176..9598398d5 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -1,6 +1,6 @@ name = "testing-rust-worker" workers_dev = true -compatibility_date = "2025-09-23" # required +compatibility_date = "2025-09-23" # required main = "build/worker/shim.mjs" kv_namespaces = [ diff --git a/worker-sys/src/types/durable_object/sync_kv_storage.rs b/worker-sys/src/types/durable_object/sync_kv_storage.rs index 60ca5bb48..99d2cefbc 100644 --- a/worker-sys/src/types/durable_object/sync_kv_storage.rs +++ b/worker-sys/src/types/durable_object/sync_kv_storage.rs @@ -1,23 +1,104 @@ +#[allow(unused_imports)] +use js_sys::*; use wasm_bindgen::prelude::*; #[wasm_bindgen] extern "C" { - #[wasm_bindgen(extends = js_sys::Object)] - #[derive(Clone, Debug)] + #[wasm_bindgen(extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] pub type SyncKvStorage; + /// Retrieves the value associated with the given key. + /// + /// Returns `undefined` if the key does not exist. #[wasm_bindgen(method)] pub fn get(this: &SyncKvStorage, key: &str) -> JsValue; + /// Retrieves the value associated with the given key. + /// + /// Returns `undefined` if the key does not exist. + #[wasm_bindgen(method, catch, js_name = "get")] + pub fn try_get(this: &SyncKvStorage, key: &str) -> Result; + /// Stores the value at the given key. #[wasm_bindgen(method)] - pub fn put(this: &SyncKvStorage, key: &str, value: JsValue); + pub fn put(this: &SyncKvStorage, key: &str, value: &JsValue); + /// Stores the value at the given key. + #[wasm_bindgen(method, catch, js_name = "put")] + pub fn try_put(this: &SyncKvStorage, key: &str, value: &JsValue) -> Result<(), JsValue>; + /// Deletes the key. Returns `true` if the key existed and was removed. #[wasm_bindgen(method)] pub fn delete(this: &SyncKvStorage, key: &str) -> bool; + /// Deletes the key. Returns `true` if the key existed and was removed. + #[wasm_bindgen(method, catch, js_name = "delete")] + pub fn try_delete(this: &SyncKvStorage, key: &str) -> Result; + /// Returns an iterable of `[key, value]` pairs in ascending lexicographic order. #[wasm_bindgen(method)] - pub fn list(this: &SyncKvStorage) -> js_sys::Object; + pub fn list(this: &SyncKvStorage) -> JsValue; + /// Returns an iterable of `[key, value]` pairs in ascending lexicographic order. + #[wasm_bindgen(method, catch, js_name = "list")] + pub fn try_list(this: &SyncKvStorage) -> Result; + /// Returns an iterable of `[key, value]` pairs filtered by the given options. + #[wasm_bindgen(method, js_name = "list")] + pub fn list_with_options(this: &SyncKvStorage, options: &SyncKvListOptions) -> JsValue; + /// Returns an iterable of `[key, value]` pairs filtered by the given options. + #[wasm_bindgen(method, catch, js_name = "list")] + pub fn try_list_with_options( + this: &SyncKvStorage, + options: &SyncKvListOptions, + ) -> Result; +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type SyncKvListOptions; + + /// Key at which the list results should start, inclusive. + #[wasm_bindgen(method, getter)] + pub fn start(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_start(this: &SyncKvListOptions, val: &str); + + /// Key at which the list results should start, exclusive. Cannot be used + /// simultaneously with `start`. + #[wasm_bindgen(method, getter, js_name = "startAfter")] + pub fn start_after(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter, js_name = "startAfter")] + pub fn set_start_after(this: &SyncKvListOptions, val: &str); + + /// Key at which the list results should end, exclusive. + #[wasm_bindgen(method, getter)] + pub fn end(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_end(this: &SyncKvListOptions, val: &str); + + /// Restricts results to only include key-value pairs whose keys begin with the prefix. + #[wasm_bindgen(method, getter)] + pub fn prefix(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_prefix(this: &SyncKvListOptions, val: &str); + + /// If true, return results in descending lexicographic order. + #[wasm_bindgen(method, getter)] + pub fn reverse(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_reverse(this: &SyncKvListOptions, val: bool); + + /// Maximum number of key-value pairs to return. + #[wasm_bindgen(method, getter)] + pub fn limit(this: &SyncKvListOptions) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_limit(this: &SyncKvListOptions, val: f64); +} - #[wasm_bindgen(method, js_name = list)] - pub fn list_with_options(this: &SyncKvStorage, options: js_sys::Object) -> js_sys::Object; +impl SyncKvListOptions { + /// Create an empty `SyncKvListOptions` JS object. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + use wasm_bindgen::JsCast; + JsCast::unchecked_into(js_sys::Object::new()) + } } diff --git a/worker/src/durable.rs b/worker/src/durable.rs index 14f530a0f..8218629eb 100644 --- a/worker/src/durable.rs +++ b/worker/src/durable.rs @@ -572,7 +572,7 @@ impl Storage { /// Access the synchronous key-value APIs exposed at `ctx.storage.kv`. /// - /// This is Cloudflare's Synchronous KV API for SQLite-backed Durable Objects + /// This is Cloudflare's Synchronous KV API for SQLite-backed Durable Objects. pub fn kv(&self) -> crate::sync_kv::SyncKvStorage { crate::sync_kv::SyncKvStorage::new(self.inner.kv()) } @@ -686,10 +686,6 @@ pub struct ListOptions<'a> { /// Key at which the list results should start, inclusive. #[serde(skip_serializing_if = "Option::is_none")] start: Option<&'a str>, - /// Key at which the list results should start, exclusive. - /// Cannot be used simultaneously with start. - #[serde(rename = "startAfter", skip_serializing_if = "Option::is_none")] - pub start_after: Option<&'a str>, /// Key at which the list results should end, exclusive. #[serde(skip_serializing_if = "Option::is_none")] end: Option<&'a str>, @@ -717,13 +713,6 @@ impl<'a> ListOptions<'a> { self } - /// Key at which the list results should start, exclusive. - /// Cannot be used simultaneously with start. - pub fn start_after(mut self, val: &'a str) -> Self { - self.start_after = Some(val); - self - } - /// Key at which the list results should end, exclusive. pub fn end(mut self, val: &'a str) -> Self { self.end = Some(val); diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 7503c5a7f..86af2f53f 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -240,9 +240,9 @@ pub mod signal; mod socket; mod sql; mod streams; +mod sync_kv; mod version; mod websocket; -mod sync_kv; /// A `Result` alias defaulting to [`Error`]. pub type Result = StdResult; diff --git a/worker/src/sync_kv.rs b/worker/src/sync_kv.rs index ac2b420fc..c96288f74 100644 --- a/worker/src/sync_kv.rs +++ b/worker/src/sync_kv.rs @@ -1,29 +1,37 @@ -//! Bindings for Cloudflare Durable Objects' Synchronous KV API exposed via +//! Cloudflare Durable Objects' Synchronous KV API exposed via //! [`Storage::kv`](crate::Storage::kv). //! //! This is the `ctx.storage.kv` API available on SQLite-backed Durable Objects. //! Entries are stored in the Durable Object's hidden `__cf_kv` SQLite table. -//! Values are converted with [`serde_wasm_bindgen`], allowing typed access through `serde`. +//! +//! [`SyncKvListOptions`] is re-exported directly from `worker-sys` as an +//! imported JS handle; build one with [`SyncKvListOptionsBuilder`]. +//! [`SyncKvStorage`] is a thin Rust wrapper that adds `serde`-aware typed +//! `get`/`put`/`list` methods on top of the imported sys type. use core::fmt; use std::marker::PhantomData; use serde::{de::DeserializeOwned, Serialize}; use serde_wasm_bindgen as swb; -use wasm_bindgen::JsCast as _; +use wasm_bindgen::{JsCast as _, JsValue}; use worker_sys::types::SyncKvStorage as SyncKvStorageSys; -use crate::{Error, ListOptions, Result}; +pub use worker_sys::types::SyncKvListOptions; + +use crate::{Error, Result}; /// Cloudflare Durable Objects' Synchronous KV API exposed by [`Storage::kv`](crate::Storage::kv). /// -/// This is the `ctx.storage.kv` interface for SQLite-backed Durable Objects. -/// Values are serialized with [`Serialize`] and deserialized with [`DeserializeOwned`]. -#[derive(Clone)] +/// Wraps the imported [`worker_sys::types::SyncKvStorage`] handle and provides +/// typed `serde`-aware methods. +#[derive(Clone, Debug)] pub struct SyncKvStorage { inner: SyncKvStorageSys, } +// SAFETY: workers run single-threaded; matches the convention used by other +// Durable Object wrappers in this crate (e.g. `Storage`, `SqlStorage`). unsafe impl Send for SyncKvStorage {} unsafe impl Sync for SyncKvStorage {} @@ -31,9 +39,7 @@ impl SyncKvStorage { pub(crate) fn new(inner: SyncKvStorageSys) -> Self { Self { inner } } -} -impl SyncKvStorage { /// Retrieves the value associated with the given key. /// /// Returns `Ok(None)` if the key does not exist. @@ -41,8 +47,7 @@ impl SyncKvStorage { where T: DeserializeOwned, { - let val = self.inner.get(key); - + let val = self.inner.try_get(key).map_err(Error::from)?; if val.is_undefined() { Ok(None) } else { @@ -56,27 +61,125 @@ impl SyncKvStorage { T: Serialize, { let js = swb::to_value(&value)?; - self.inner.put(key, js); - Ok(()) + self.inner.try_put(key, &js).map_err(Error::from) } /// Deletes the key and associated value. /// - /// Returns `true` if the key existed and was removed, or `false` if it did not exist. - pub fn delete(&self, key: &str) -> bool { - self.inner.delete(key) + /// Returns `Ok(true)` if the key existed and was removed. + pub fn delete(&self, key: &str) -> Result { + self.inner.try_delete(key).map_err(Error::from) + } + + /// Returns an iterator over all key-value pairs in ascending lexicographic order. + pub fn list(&self) -> Result> + where + T: DeserializeOwned, + { + let iterable = self.inner.try_list().map_err(Error::from)?; + SyncKvIterator::from_iterable(&iterable) + } + + /// Returns an iterator over key-value pairs that match the provided [`SyncKvListOptions`]. + pub fn list_with_options(&self, options: &SyncKvListOptions) -> Result> + where + T: DeserializeOwned, + { + let iterable = self + .inner + .try_list_with_options(options) + .map_err(Error::from)?; + SyncKvIterator::from_iterable(&iterable) + } +} + +/// Fluent builder for [`SyncKvListOptions`]. +/// +/// Each method mutates the underlying JS object in place and returns `self`. +/// [`build`](Self::build) returns the configured imported handle. +#[derive(Debug)] +pub struct SyncKvListOptionsBuilder { + inner: SyncKvListOptions, +} + +impl Default for SyncKvListOptionsBuilder { + fn default() -> Self { + Self { + inner: SyncKvListOptions::new(), + } + } +} + +impl SyncKvListOptionsBuilder { + /// Create an empty builder. + pub fn new() -> Self { + Self::default() + } + + /// Key at which the list results should start, inclusive. + pub fn start(self, val: &str) -> Self { + self.inner.set_start(val); + self + } + + /// Key at which the list results should start, exclusive. + /// Cannot be used simultaneously with [`start`](Self::start). + pub fn start_after(self, val: &str) -> Self { + self.inner.set_start_after(val); + self + } + + /// Key at which the list results should end, exclusive. + pub fn end(self, val: &str) -> Self { + self.inner.set_end(val); + self + } + + /// Restricts results to only include key-value pairs whose keys begin with the prefix. + pub fn prefix(self, val: &str) -> Self { + self.inner.set_prefix(val); + self + } + + /// If true, return results in descending lexicographic order. + pub fn reverse(self, val: bool) -> Self { + self.inner.set_reverse(val); + self + } + + /// Maximum number of key-value pairs to return. + pub fn limit(self, val: u32) -> Self { + self.inner.set_limit(val as f64); + self + } + + /// Consume the builder and return the configured [`SyncKvListOptions`]. + pub fn build(self) -> SyncKvListOptions { + self.inner } } /// Iterator over typed entries returned by [`SyncKvStorage::list`] and /// [`SyncKvStorage::list_with_options`]. /// -/// Each item yields the key together with its deserialized value. +/// Each item yields the key together with its `serde`-deserialized value. pub struct SyncKvIterator { inner: js_sys::IntoIter, _phantom: PhantomData, } +impl SyncKvIterator { + fn from_iterable(iterable: &JsValue) -> Result { + let inner = js_sys::try_iter(iterable)?.ok_or_else(|| { + Error::JsError("SyncKvStorage.list() did not return an iterable".into()) + })?; + Ok(Self { + inner, + _phantom: PhantomData, + }) + } +} + impl Iterator for SyncKvIterator where T: DeserializeOwned, @@ -84,27 +187,27 @@ where type Item = Result<(String, T)>; fn next(&mut self) -> Option { - let result = match self.inner.next()? { + let entry = match self.inner.next()? { Ok(r) => r, Err(e) => return Some(Err(Error::from(e))), }; - if !js_sys::Array::is_array(&result) { - return Some(Err(Error::JsError("Expected result to be array".into()))); - } - - let arr: js_sys::Array = result.unchecked_into(); - - if arr.length() < 2 { - return Some(Err(Error::JsError( - "Expected entry to have at least 2 elements".into(), - ))); - } + // workerd guarantees `[key, value]` tuples; treat anything else as a JS error. + let arr: js_sys::Array = match entry.dyn_into() { + Ok(a) => a, + Err(_) => { + return Some(Err(Error::JsError( + "Expected SyncKvStorage list entry to be an array".into(), + ))); + } + }; let key = match arr.get(0).as_string() { Some(k) => k, None => { - return Some(Err(Error::JsError("Expected key to be string".into()))); + return Some(Err(Error::JsError( + "Expected SyncKvStorage list entry key to be a string".into(), + ))); } }; @@ -117,51 +220,8 @@ where } } -impl SyncKvStorage { - const ERR_NOT_AN_ITERABLE: &str = "SyncKvStorage.list() did not return an iterable"; - - /// Returns an iterator over all key-value pairs in ascending lexicographic order. - /// - /// Each iterator item contains the key and a value deserialized as `T`. - pub fn list(&self) -> Result> - where - T: DeserializeOwned, - { - let inner = js_sys::try_iter(&self.inner.list())? - .ok_or_else(|| Error::JsError(Self::ERR_NOT_AN_ITERABLE.into()))?; - - Ok(SyncKvIterator { - inner, - _phantom: PhantomData, - }) - } - - /// Returns an iterator over key-value pairs that match the provided [`ListOptions`]. - /// - /// Each iterator item contains the key and a value deserialized as `T`. - pub fn list_with_options(&self, options: ListOptions<'_>) -> Result> { - let js_opts = swb::to_value(&options)?; - - let iter = self.inner.list_with_options(js_opts.into()); - - let inner = js_sys::try_iter(&iter)? - .ok_or_else(|| Error::JsError(Self::ERR_NOT_AN_ITERABLE.into()))?; - - Ok(SyncKvIterator { - inner, - _phantom: PhantomData, - }) - } -} - -impl fmt::Debug for SyncKvStorage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SyncKvStorage").finish() - } -} - impl fmt::Debug for SyncKvIterator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SyncKvIterator").finish() } }