From 292bc4a9b978de25253a4280e9f3e53ef81052f2 Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Mon, 28 Jul 2025 21:19:34 +0200 Subject: [PATCH 1/2] define a Serializer to JS values --- .../core/proptest-regressions/host/v8/ser.txt | 8 + crates/core/src/host/v8/de.rs | 21 +- crates/core/src/host/v8/mod.rs | 1 + crates/core/src/host/v8/ser.rs | 312 ++++++++++++++++++ crates/core/src/host/v8/to_value.rs | 28 +- crates/sats/src/lib.rs | 17 + 6 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 crates/core/proptest-regressions/host/v8/ser.txt create mode 100644 crates/core/src/host/v8/ser.rs diff --git a/crates/core/proptest-regressions/host/v8/ser.txt b/crates/core/proptest-regressions/host/v8/ser.txt new file mode 100644 index 00000000000..5c6feb88ad5 --- /dev/null +++ b/crates/core/proptest-regressions/host/v8/ser.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b4eaf1389ae0810b57684a9ca8315fb70e1bd59227a979fb90ac2885d9887684 # shrinks to (ty, val) = (Product(ProductType {"field_0": Bool}), Product(ProductValue { elements: [Bool(false)] })) +cc 191c44593d03c0c9d47fed4ccc34d87b824f3964cf11ba87cc324cf67fea475c # shrinks to (ty, val) = (Sum(SumType {"variant_0": Bool}), Sum(SumValue { tag: 0, value: Bool(false) })) diff --git a/crates/core/src/host/v8/de.rs b/crates/core/src/host/v8/de.rs index 0a17cf05e24..fb1658dd46e 100644 --- a/crates/core/src/host/v8/de.rs +++ b/crates/core/src/host/v8/de.rs @@ -11,7 +11,7 @@ use spacetimedb_sats::de::{ }; use spacetimedb_sats::{i256, u256}; use std::borrow::{Borrow, Cow}; -use v8::{Array, Global, HandleScope, Local, Object, Uint8Array, Value}; +use v8::{Array, Global, HandleScope, Local, Name, Object, Uint8Array, Value}; /// Deserializes from V8 values. pub(super) struct Deserializer<'a, 's> { @@ -48,7 +48,7 @@ impl<'s> DeserializerCommon<'_, 's> { } /// The possible errors that [`Deserializer`] can produce. -#[derive(From)] +#[derive(Debug, From)] pub(super) enum Error<'s> { Value(Local<'s, Value>), Exception(ExceptionThrown), @@ -104,7 +104,7 @@ impl KeyCache { } // Creates an interned [`v8::String`]. -fn v8_interned_string<'s>(scope: &mut HandleScope<'s>, field: &str) -> Local<'s, v8::String> { +pub(super) fn v8_interned_string<'s>(scope: &mut HandleScope<'s>, field: &str) -> Local<'s, v8::String> { // Internalized v8 strings are significantly faster than "normal" v8 strings // since v8 deduplicates re-used strings minimizing new allocations // see: https://github.com/v8/v8/blob/14ac92e02cc3db38131a57e75e2392529f405f2f/include/v8.h#L3165-L3171 @@ -268,6 +268,15 @@ struct ProductAccess<'a, 's> { index: usize, } +/// Normalizes `field` into an interned `v8::String`. +pub(super) fn intern_field_name<'s>(scope: &mut HandleScope<'s>, field: Option<&str>, index: usize) -> Local<'s, Name> { + let field = match field { + Some(field) => Cow::Borrowed(field), + None => Cow::Owned(format!("{index}")), + }; + v8_interned_string(scope, field).into() +} + impl<'de, 's: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 's> { type Error = Error<'s>; @@ -282,13 +291,9 @@ impl<'de, 's: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 's> { // Normalize the field name. // Integer keys are converted to strings, // as that is supported on JS objects. - let field = match field { - Some(field) => Cow::Borrowed(field), - None => Cow::Owned(format!("{index}")), - }; + let key = intern_field_name(scope, field, index); // Check that such a field/key exists. - let key = v8_interned_string(scope, &field).into(); if !self .object .has_own_property(scope, key) diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index ba128aa7588..7a4b26ff8bb 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -15,6 +15,7 @@ use std::sync::{Arc, LazyLock}; mod de; mod error; mod from_value; +mod ser; mod to_value; /// The V8 runtime, for modules written in e.g., JS or TypeScript. diff --git a/crates/core/src/host/v8/ser.rs b/crates/core/src/host/v8/ser.rs new file mode 100644 index 00000000000..bd0aad4e9dc --- /dev/null +++ b/crates/core/src/host/v8/ser.rs @@ -0,0 +1,312 @@ +#![allow(dead_code)] + +use super::de::{intern_field_name, KeyCache}; +use super::error::{exception_already_thrown, ExceptionThrown}; +use super::to_value::ToValue; +use core::num::TryFromIntError; +use derive_more::From; +use spacetimedb_sats::{ + i256, + ser::{self, Serialize}, + u256, +}; +use v8::{Array, ArrayBuffer, HandleScope, IntegrityLevel, Local, Object, Uint8Array, Value}; + +/// Deserializes to V8 values. +pub(super) struct Serializer<'a, 's> { + /// The scope to serialize values into. + scope: &'a mut HandleScope<'s>, + /// A cache for frequently used strings. + key_cache: &'a mut KeyCache, +} + +impl<'a, 's> Serializer<'a, 's> { + /// Creates a new serializer into `scope`. + pub fn new(scope: &'a mut HandleScope<'s>, key_cache: &'a mut KeyCache) -> Self { + Self { scope, key_cache } + } + + fn reborrow(&mut self) -> Serializer<'_, 's> { + Serializer { + scope: self.scope, + key_cache: self.key_cache, + } + } +} + +/// The possible errors that [`Serializer`] can produce. +#[derive(Debug, From)] +pub(super) enum Error { + Custom(String), + Exception(ExceptionThrown), + StringTooLarge(usize), + ArrayLengthTooLarge(TryFromIntError), +} + +impl ser::Error for Error { + fn custom(msg: T) -> Self { + Self::Custom(msg.to_string()) + } +} + +/// Serializes a primitive via [`ToValue`]. +macro_rules! serialize_primitive { + ($method:ident, $ty:ty) => { + fn $method(self, val: $ty) -> Result { + Ok(ToValue::to_value(&val, self.scope)) + } + }; +} + +/// Seal the object, so that e.g., new properties cannot be added. +/// +/// However, the values of existing properties may be modified, +/// which can be useful if the module wants to modify a property +/// and then send the object back. +fn seal_object(scope: &mut HandleScope, object: &Object) -> Result<(), ExceptionThrown> { + let _ = object + .set_integrity_level(scope, IntegrityLevel::Sealed) + .ok_or_else(exception_already_thrown)?; + Ok(()) +} + +impl<'a, 's> ser::Serializer for Serializer<'a, 's> { + type Ok = Local<'s, Value>; + type Error = Error; + + type SerializeArray = SerializeArray<'a, 's>; + type SerializeSeqProduct = Self::SerializeNamedProduct; + type SerializeNamedProduct = SerializeNamedProduct<'a, 's>; + + // Serialization of primitive types defers to `ToValue`. + serialize_primitive!(serialize_bool, bool); + serialize_primitive!(serialize_u8, u8); + serialize_primitive!(serialize_u16, u16); + serialize_primitive!(serialize_u32, u32); + serialize_primitive!(serialize_u64, u64); + serialize_primitive!(serialize_u128, u128); + serialize_primitive!(serialize_u256, u256); + serialize_primitive!(serialize_i8, i8); + serialize_primitive!(serialize_i16, i16); + serialize_primitive!(serialize_i32, i32); + serialize_primitive!(serialize_i64, i64); + serialize_primitive!(serialize_i128, i128); + serialize_primitive!(serialize_i256, i256); + serialize_primitive!(serialize_f64, f64); + serialize_primitive!(serialize_f32, f32); + + fn serialize_str(self, string: &str) -> Result { + v8::String::new(self.scope, string) + .map(Into::into) + .ok_or(Error::StringTooLarge(string.len())) + } + + fn serialize_bytes(self, bytes: &[u8]) -> Result { + let store = ArrayBuffer::new_backing_store_from_boxed_slice(bytes.into()).make_shared(); + let buf = ArrayBuffer::with_backing_store(self.scope, &store); + Ok(Uint8Array::new(self.scope, buf, 0, bytes.len()).unwrap().into()) + } + + fn serialize_array(self, len: usize) -> Result { + let len = len.try_into()?; + Ok(SerializeArray { + array: Array::new(self.scope, len), + inner: self, + next_index: 0, + }) + } + + fn serialize_seq_product(self, len: usize) -> Result { + self.serialize_named_product(len) + } + + fn serialize_named_product(self, _len: usize) -> Result { + // TODO(noa): this can be more efficient if we tell it the names ahead of time + let object = Object::new(self.scope); + Ok(SerializeNamedProduct { + inner: self, + object, + next_index: 0, + }) + } + + fn serialize_variant( + mut self, + tag: u8, + var_name: Option<&str>, + value: &T, + ) -> Result { + // Serialize the payload. + let value_value: Local<'s, Value> = value.serialize(self.reborrow())?; + // Figure out the tag. + let tag_value: Local<'s, Value> = intern_field_name(self.scope, var_name, tag as usize).into(); + let values = [tag_value, value_value]; + + // The property keys are always `"tag"` an `"value"`. + let names = [ + self.key_cache.tag(self.scope).into(), + self.key_cache.value(self.scope).into(), + ]; + + // Stitch together the object. + let prototype = v8::null(self.scope).into(); + let object = Object::with_prototype_and_properties(self.scope, prototype, &names, &values); + seal_object(self.scope, &object)?; + Ok(object.into()) + } +} + +/// Serializes array elements and finalizes the JS array. +pub(super) struct SerializeArray<'a, 's> { + inner: Serializer<'a, 's>, + array: Local<'s, Array>, + next_index: u32, +} + +impl<'s> ser::SerializeArray for SerializeArray<'_, 's> { + type Ok = Local<'s, Value>; + type Error = Error; + + fn serialize_element(&mut self, elem: &T) -> Result<(), Self::Error> { + // Serialize the current `elem`ent. + let value = elem.serialize(self.inner.reborrow())?; + + // Set the value to the array slot at `index`. + let index = self.next_index; + self.next_index += 1; + self.array + .set_index(self.inner.scope, index, value) + .ok_or_else(exception_already_thrown)?; + + Ok(()) + } + + fn end(self) -> Result { + Ok(self.array.into()) + } +} + +/// Serializes into JS objects where field names are turned into property names. +pub(super) struct SerializeNamedProduct<'a, 's> { + inner: Serializer<'a, 's>, + object: Local<'s, Object>, + next_index: usize, +} + +impl<'s> ser::SerializeSeqProduct for SerializeNamedProduct<'_, 's> { + type Ok = Local<'s, Value>; + type Error = Error; + + fn serialize_element(&mut self, elem: &T) -> Result<(), Self::Error> { + ser::SerializeNamedProduct::serialize_element(self, None, elem) + } + + fn end(self) -> Result { + ser::SerializeNamedProduct::end(self) + } +} + +impl<'s> ser::SerializeNamedProduct for SerializeNamedProduct<'_, 's> { + type Ok = Local<'s, Value>; + type Error = Error; + + fn serialize_element( + &mut self, + field_name: Option<&str>, + elem: &T, + ) -> Result<(), Self::Error> { + // Serialize the field value. + let value = elem.serialize(self.inner.reborrow())?; + + // Figure out the object property to use. + let scope = &mut *self.inner.scope; + let index = self.next_index; + self.next_index += 1; + let key = intern_field_name(scope, field_name, index).into(); + + // Set the value to the property. + self.object + .set(scope, key, value) + .ok_or_else(exception_already_thrown)?; + + Ok(()) + } + + fn end(self) -> Result { + seal_object(self.inner.scope, &self.object)?; + Ok(self.object.into()) + } +} + +#[cfg(test)] +mod test { + use super::super::de::Deserializer; + use super::super::to_value::test::with_scope; + use super::*; + use core::fmt::Debug; + use proptest::prelude::*; + use spacetimedb_lib::{AlgebraicType, AlgebraicValue}; + use spacetimedb_sats::de::DeserializeSeed; + use spacetimedb_sats::proptest::generate_typed_value; + use spacetimedb_sats::{product, SumValue, ValueWithType, WithTypespace}; + use AlgebraicType::Bool; + + /// Roundtrips `rust_val` via [`Serialize`] to the V8 representation + /// and then back via [`DeserializeSeed`], + /// asserting that it's the same as the passed value. + fn assert_roundtrips( + rust_val: impl Serialize + PartialEq + Debug, + seed: impl for<'de> DeserializeSeed<'de, Output = B>, + ) { + with_scope(|scope| { + let key_cache = &mut KeyCache::default(); + + // Convert to JS... + let ser = Serializer::new(scope, key_cache); + let js_val = rust_val.serialize(ser).unwrap(); + + // ...and then back to Rust. + let de = Deserializer::new(scope, js_val, key_cache); + let rust_val_prime = seed.deserialize(de).unwrap(); + + // We should end up where we started. + assert_eq!(rust_val, rust_val_prime); + }) + } + + fn assert_roundtrips_with_ty(ty: AlgebraicType, val: AlgebraicValue) { + let ctx = WithTypespace::empty(&ty); + let value = ValueWithType::new(ctx, &val); + let seed = value.ty_s(); + assert_roundtrips(value, seed); + } + + proptest! { + #[test] + fn test_random_typed_value_roundtrips((ty, val) in generate_typed_value()) { + assert_roundtrips_with_ty(ty, val); + } + } + + #[test] + fn anonymized_product_works() { + let ty = AlgebraicType::product([Bool]); + let val = product![false].into(); + assert_roundtrips_with_ty(ty, val); + } + + /// This test demonstrates that serialization misbehaves without using [`ValueWithType`]. + #[test] + fn regression_test_product_serialization_needs_value_with_type() { + let ty = AlgebraicType::product([("field_0", Bool)]); + let val = product![false].into(); + assert_roundtrips_with_ty(ty, val); + } + + #[test] + fn regression_test_variant() { + let ty = AlgebraicType::sum([("variant_0", Bool)]); + let val = SumValue::new(0, false).into(); + assert_roundtrips_with_ty(ty, val); + } +} diff --git a/crates/core/src/host/v8/to_value.rs b/crates/core/src/host/v8/to_value.rs index 4ab82496104..7bd8c24bf76 100644 --- a/crates/core/src/host/v8/to_value.rs +++ b/crates/core/src/host/v8/to_value.rs @@ -103,7 +103,7 @@ impl_to_value!(i256, (val, scope) => { }); #[cfg(test)] -mod test { +pub(in super::super) mod test { use super::super::from_value::FromValue; use super::super::V8Runtime; use super::*; @@ -112,23 +112,29 @@ mod test { use spacetimedb_sats::proptest::{any_i256, any_u256}; use v8::{Context, ContextScope, HandleScope, Isolate}; - /// Roundtrips `rust_val` via `ToValue` to the V8 representation - /// and then back via `FromValue`, - /// asserting that it's the same as the passed value. - fn assert_roundtrips(rust_val: T) { - // Setup V8 and get a `HandleScope`. + /// Sets up V8 and runs `logic` with a [`HandleScope`]. + pub(in super::super) fn with_scope(logic: impl FnOnce(&mut HandleScope<'_>) -> R) -> R { V8Runtime::init_for_test(); let isolate = &mut Isolate::new(<_>::default()); let scope = &mut HandleScope::new(isolate); let context = Context::new(scope, Default::default()); let scope = &mut ContextScope::new(scope, context); - // Convert to JS and then back. - let js_val = rust_val.to_value(scope); - let rust_val_prime = T::from_value(js_val, scope).unwrap(); + logic(scope) + } - // We should end up where we started. - assert_eq!(rust_val, rust_val_prime); + /// Roundtrips `rust_val` via `ToValue` to the V8 representation + /// and then back via `FromValue`, + /// asserting that it's the same as the passed value. + fn assert_roundtrips(rust_val: T) { + with_scope(|scope| { + // Convert to JS and then back. + let js_val = rust_val.to_value(scope); + let rust_val_prime = T::from_value(js_val, scope).unwrap(); + + // We should end up where we started. + assert_eq!(rust_val, rust_val_prime); + }) } proptest! { diff --git a/crates/sats/src/lib.rs b/crates/sats/src/lib.rs index 39b95fa7d5e..752ea3baed3 100644 --- a/crates/sats/src/lib.rs +++ b/crates/sats/src/lib.rs @@ -162,6 +162,23 @@ impl<'a, T: Value> ValueWithType<'a, Box<[T]>> { } } +impl PartialEq for ValueWithType<'_, T> { + fn eq(&self, other: &T) -> bool { + self.val == other + } +} + +use core::fmt; + +impl> fmt::Debug for ValueWithType<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ValueWithType") + .field("type", self.ty()) + .field("value", self.value()) + .finish() + } +} + /// Adds a `Typespace` context atop of a borrowed type. #[derive(Debug)] pub struct WithTypespace<'a, T: ?Sized> { From 036d03dbaef230c51f9895f305b56986f929e799 Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Wed, 30 Jul 2025 22:52:29 +0200 Subject: [PATCH 2/2] fix intern_field_name --- crates/core/src/host/v8/de.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/host/v8/de.rs b/crates/core/src/host/v8/de.rs index fb1658dd46e..0c1a641c116 100644 --- a/crates/core/src/host/v8/de.rs +++ b/crates/core/src/host/v8/de.rs @@ -274,7 +274,7 @@ pub(super) fn intern_field_name<'s>(scope: &mut HandleScope<'s>, field: Option<& Some(field) => Cow::Borrowed(field), None => Cow::Owned(format!("{index}")), }; - v8_interned_string(scope, field).into() + v8_interned_string(scope, &field).into() } impl<'de, 's: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 's> {