diff --git a/Cargo.lock b/Cargo.lock index 36065e3f92a..a607da9d129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8130,6 +8130,7 @@ dependencies = [ "spacetimedb-lib", "spacetimedb-primitives", "spacetimedb-query-builder", + "spacetimedb-schema", "spacetimedb-test-datastore", "trybuild", ] @@ -8940,6 +8941,7 @@ dependencies = [ "lean_string", "petgraph 0.6.5", "proptest", + "serde_json", "serial_test", "smallvec", "spacetimedb-data-structures", diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawLocalOutboxDefV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawLocalOutboxDefV10.g.cs new file mode 100644 index 00000000000..49093a40ad8 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawLocalOutboxDefV10.g.cs @@ -0,0 +1,49 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class RawLocalOutboxDefV10 + { + [DataMember(Name = "table_name")] + public string TableName; + [DataMember(Name = "reducer_source_name")] + public string ReducerSourceName; + [DataMember(Name = "target_column")] + public ushort TargetColumn; + [DataMember(Name = "arg_columns")] + public System.Collections.Generic.List ArgColumns; + [DataMember(Name = "on_result_reducer")] + public string? OnResultReducer; + + public RawLocalOutboxDefV10( + string TableName, + string ReducerSourceName, + ushort TargetColumn, + System.Collections.Generic.List ArgColumns, + string? OnResultReducer + ) + { + this.TableName = TableName; + this.ReducerSourceName = ReducerSourceName; + this.TargetColumn = TargetColumn; + this.ArgColumns = ArgColumns; + this.OnResultReducer = OnResultReducer; + } + + public RawLocalOutboxDefV10() + { + this.TableName = ""; + this.ReducerSourceName = ""; + this.ArgColumns = new(); + } + } +} diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index da4b47dbb78..457550e9e74 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -25,6 +25,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List ViewPrimaryKeys, System.Collections.Generic.List Submodules, System.Collections.Generic.List Environment, - System.Collections.Generic.List Outboxes + System.Collections.Generic.List Outboxes, + System.Collections.Generic.List LocalOutboxes )>; } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawOutboxDefV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawOutboxDefV10.g.cs index 217ce53885b..51853ece6af 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawOutboxDefV10.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawOutboxDefV10.g.cs @@ -24,7 +24,7 @@ public sealed partial class RawOutboxDefV10 [DataMember(Name = "on_result_reducer")] public string? OnResultReducer; [DataMember(Name = "signature_hash")] - public string SignatureHash; + public U256 SignatureHash; public RawOutboxDefV10( string TableName, @@ -32,7 +32,7 @@ public RawOutboxDefV10( ushort TargetColumn, System.Collections.Generic.List ArgColumns, string? OnResultReducer, - string SignatureHash + U256 SignatureHash ) { this.TableName = TableName; @@ -48,7 +48,6 @@ public RawOutboxDefV10() this.TableName = ""; this.RemoteReducer = ""; this.ArgColumns = new(); - this.SignatureHash = ""; } } } diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index 7f30fc22372..e2f7016f88e 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -159,6 +159,10 @@ mod sym { symbol!(repr); symbol!(sats); symbol!(scheduled); + symbol!(outbox); + symbol!(on_result); + symbol!(param); + symbol!(target); symbol!(unique); symbol!(update); symbol!(default); @@ -229,7 +233,10 @@ fn derive_table_helper_attr() -> Attribute { /// /// Provides helper attributes for `#[spacetimedb::table]`, so that we don't get unknown attribute errors. #[doc(hidden)] -#[proc_macro_derive(__TableHelper, attributes(sats, unique, auto_inc, primary_key, index, default))] +#[proc_macro_derive( + __TableHelper, + attributes(sats, unique, auto_inc, primary_key, target, index, default, param) +)] pub fn table_helper(input: StdTokenStream) -> StdTokenStream { schema_type(input) } diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs index 0193f2033dd..93ccbcf6ca6 100644 --- a/crates/bindings-macro/src/reducer.rs +++ b/crates/bindings-macro/src/reducer.rs @@ -1,7 +1,8 @@ use crate::sym; use crate::util::{check_duplicate, check_duplicate_msg, ident_to_litstr, match_meta, native_test_utils_registration}; use proc_macro2::{Span, TokenStream}; -use quote::{quote, quote_spanned}; +use quote::{format_ident, quote, quote_spanned}; +use syn::ext::IdentExt; use syn::parse::Parser as _; use syn::spanned::Spanned; use syn::{FnArg, Ident, ItemFn, LitStr, PatType}; @@ -10,6 +11,7 @@ use syn::{FnArg, Ident, ItemFn, LitStr, PatType}; pub(crate) struct ReducerArgs { name: Option, lifecycle: Option, + on_result: Option, } enum LifecycleReducer { @@ -51,6 +53,12 @@ impl ReducerArgs { check_duplicate(&args.name, &meta)?; args.name = Some(meta.value()?.parse()?); } + sym::on_result => { + check_duplicate(&args.on_result, &meta)?; + let content; + syn::parenthesized!(content in meta.input); + args.on_result = Some(content.parse()?); + } }); Ok(()) }) @@ -101,12 +109,26 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn assert_only_lifetime_generics(original_function, "reducers")?; let lifecycle = args.lifecycle.iter().filter_map(|lc| lc.to_lifecycle_value()); + let on_result_outbox = args.on_result.as_ref().map(|path| { + let accessor = &path.segments.last().unwrap().ident; + quote!( + const ON_RESULT_OUTBOX: Option<&'static str> = + Some(spacetimedb::rt::on_result_outbox(#path::#accessor)); + ) + }); + let on_result_typecheck = args.on_result.as_ref().map(|path| { + let accessor = &path.segments.last().unwrap().ident; + quote! { + spacetimedb::rt::on_result_typecheck(#func_name, #path::#accessor); + } + }); let typed_args = extract_typed_args(original_function)?; let explicit_name = args.name.as_ref(); let generate_explicit_names = generate_explicit_names_impl(&reducer_name.value(), func_name, explicit_name); + let outbox_typecheck = generate_local_outbox_typecheck(func_name, &typed_args); // Extract all function parameter names. let opt_arg_names = typed_args.iter().map(|arg| { @@ -156,6 +178,7 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn #(let _ = <#first_arg_ty as spacetimedb::rt::ReducerContextArg>::_ITEM;)* #(let _ = <#rest_arg_tys as spacetimedb::rt::ReducerArg>::_ITEM;)* #(let _ = <#ret_ty as spacetimedb::rt::IntoReducerResult>::into_result;)* + #on_result_typecheck } }; impl #func_name { @@ -170,14 +193,91 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn type FnKind = spacetimedb::rt::FnKindReducer; const NAME: &'static str = #reducer_name; #(const LIFECYCLE: Option = Some(#lifecycle);)* + #on_result_outbox const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; const INVOKE: Self::Invoke = #func_name::invoke; } + impl spacetimedb::rt::OutboxReceiver for #func_name { + type Identity = spacetimedb::Identity; + const NAME: &'static str = ::NAME; + const SIGNATURE_HASH: Option<&'static str> = None; + } + #generate_explicit_names + #outbox_typecheck }) } +fn generate_local_outbox_typecheck(func_name: &Ident, args: &[&PatType]) -> TokenStream { + let macro_name = format_ident!("__spacetimedb_typecheck_outbox_{}", func_name.unraw()); + let mut helpers = Vec::new(); + let mut checks = Vec::new(); + for arg in args.iter().skip(1) { + let syn::Pat::Ident(pattern) = arg.pat.as_ref() else { + checks.push(quote!(compile_error!( + "local outbox reducer parameters must have names" + ))); + continue; + }; + let param = pattern.ident.unraw(); + let ty = &arg.ty; + let lookup = format_ident!("{}_{}", macro_name, param); + let reject_duplicate = format_ident!("{}_reject_duplicate", lookup); + helpers.push(quote! { + #[doc(hidden)] + macro_rules! #reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $column_ty:ty as #param), $($rest:tt)*) => { + compile_error!(concat!("outbox has multiple columns matching reducer parameter `", stringify!(#param), "`")); + }; + ($module:ident; ($index:literal, $field:ident : $column_ty:ty as $other:ident), $($rest:tt)*) => { + $module::#reject_duplicate!($module; $($rest)*); + }; + } + #[doc(hidden)] + #[allow(unused_imports)] + pub(crate) use #reject_duplicate; + + #[doc(hidden)] + macro_rules! #lookup { + ($module:ident;) => {{ + compile_error!(concat!("outbox has no column matching reducer parameter `", stringify!(#param), "`")); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $column_ty:ty as #param), $($rest:tt)*) => {{ + #[allow(unused_imports)] + use $module::*; + let _ = |x: $column_ty| { let _: #ty = x; }; + $module::#reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $column_ty:ty as $other:ident), $($rest:tt)*) => { + $module::#lookup!($module; $($rest)*) + }; + } + #[doc(hidden)] + #[allow(unused_imports)] + pub(crate) use #lookup; + }); + checks.push(quote!(__outbox_receiver::#lookup!(__outbox_receiver; $($columns)*))); + } + quote! { + #(#helpers)* + #[doc(hidden)] + macro_rules! #macro_name { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[#(#checks),*] + }}; + } + #[doc(hidden)] + #[allow(unused_imports)] + pub(crate) use #macro_name; + } +} + pub(crate) fn generate_explicit_names_impl( func_name: &str, func_handle: &Ident, diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index e1ff4edec8a..d87f9c00d95 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -19,6 +19,7 @@ pub(crate) struct TableArgs { access: Option, name: Option, scheduled: Option, + outbox: Option, accessor: Ident, indices: Vec, event: Option, @@ -47,6 +48,10 @@ struct ScheduledArg { at: Option, } +struct OutboxArg { + receiver_reducer: Path, +} + struct IndexArg { accessor: Ident, canonical_name: Option, @@ -78,6 +83,7 @@ impl TableArgs { pub(crate) fn parse(input: TokenStream, struct_ident: &Ident) -> syn::Result { let mut access = None; let mut scheduled = None; + let mut outbox = None; let mut accessor = None; let mut name: Option = None; let mut indices = Vec::new(); @@ -145,6 +151,10 @@ If you're migrating from SpacetimeDB 1.*, replace `name = {sym}` with `accessor check_duplicate(&scheduled, &meta)?; scheduled = Some(ScheduledArg::parse_meta(meta)?); } + sym::outbox => { + check_duplicate(&outbox, &meta)?; + outbox = Some(OutboxArg::parse_meta(meta)?); + } sym::event => { check_duplicate(&event, &meta)?; event = Some(meta.path.span()); @@ -184,6 +194,7 @@ If you're migrating from SpacetimeDB 1.*, replace `name = {name_str_value:?}` wi Ok(TableArgs { access, scheduled, + outbox, accessor, indices, name, @@ -192,6 +203,22 @@ If you're migrating from SpacetimeDB 1.*, replace `name = {name_str_value:?}` wi } } +impl OutboxArg { + fn parse_meta(meta: ParseNestedMeta) -> syn::Result { + let mut receiver_reducer = None; + + meta.parse_nested_meta(|meta| { + check_duplicate_msg(&receiver_reducer, &meta, "can only specify one outbox receiver reducer")?; + receiver_reducer = Some(meta.path); + Ok(()) + })?; + + let receiver_reducer = receiver_reducer + .ok_or_else(|| meta.error("must specify outbox receiver reducer: outbox(receiver::reducer)"))?; + Ok(Self { receiver_reducer }) + } +} + impl ScheduledArg { fn parse_meta(meta: ParseNestedMeta) -> syn::Result { let span = meta.path.span(); @@ -740,6 +767,7 @@ struct Column<'a> { vis: &'a syn::Visibility, ident: &'a syn::Ident, ty: &'a syn::Type, + param_name: String, default_value: Option, } @@ -769,8 +797,10 @@ enum ColumnAttr { Unique(Span), AutoInc(Span), PrimaryKey(Span), + Target(Span), Index(IndexArg), Default(syn::Expr, Span), + ParamName(Ident), } impl ColumnAttr { @@ -790,6 +820,11 @@ impl ColumnAttr { } else if ident == sym::primary_key { attr.meta.require_path_only()?; Some(ColumnAttr::PrimaryKey(ident.span())) + } else if ident == sym::target { + attr.meta.require_path_only()?; + Some(ColumnAttr::Target(ident.span())) + } else if ident == sym::param { + Some(ColumnAttr::ParamName(parse_param_attr(attr)?)) } else if ident == sym::default { Some(parse_default_attr(attr, ident)?) } else { @@ -798,6 +833,20 @@ impl ColumnAttr { } } +fn parse_param_attr(attr: &syn::Attribute) -> syn::Result { + let mut name = None; + attr.parse_nested_meta(|meta| { + match_meta!(match meta { + sym::name => { + check_duplicate(&name, &meta)?; + name = Some(meta.value()?.parse()?); + } + }); + Ok(()) + })?; + name.ok_or_else(|| syn::Error::new_spanned(&attr.meta, "expected `#[param(name = parameter_name)]`")) +} + fn parse_default_attr(attr: &syn::Attribute, ident: &Ident) -> syn::Result { if let Ok(expr) = attr.parse_args::() { return Ok(ColumnAttr::Default(expr, ident.span())); @@ -869,6 +918,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R let mut unique_columns = vec![]; let mut sequenced_columns = vec![]; let mut primary_key_column = None; + let mut outbox_target_column = None; for (i, field) in fields.iter().enumerate() { let col_num = i as u16; @@ -877,7 +927,9 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R let mut unique = None; let mut auto_inc = None; let mut primary_key = None; + let mut target = None; let mut default_value = None; + let mut param_name = None; for attr in field.original_attrs { let Some(attr) = ColumnAttr::parse(attr, field_ident)? else { continue; @@ -895,11 +947,19 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R check_duplicate(&primary_key, span)?; primary_key = Some(span); } + ColumnAttr::Target(span) => { + check_duplicate(&target, span)?; + target = Some(span); + } ColumnAttr::Index(index_arg) => args.indices.push(index_arg), ColumnAttr::Default(expr, span) => { check_duplicate(&default_value, span)?; default_value = Some(expr); } + ColumnAttr::ParamName(name) => { + check_duplicate(¶m_name, name.span())?; + param_name = Some(name); + } } } @@ -917,6 +977,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R ident: field_ident, vis: field.vis, ty: field.ty, + param_name: param_name.as_ref().unwrap_or(field_ident).unraw().to_string(), default_value, }; @@ -930,6 +991,14 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R check_duplicate_msg(&primary_key_column, span, "can only have one primary key per table")?; primary_key_column = Some(column.clone()); } + if let Some(span) = target { + check_duplicate_msg( + &outbox_target_column, + span, + "can only have one target column per outbox table", + )?; + outbox_target_column = Some((column.clone(), span)); + } columns.push(column.clone()); } @@ -1102,7 +1171,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R `scheduled(my_reducer, at = custom_scheduled_at)`", ) })?; - let primary_key_column = primary_key_column.ok_or_else(|| { + let primary_key_column = primary_key_column.clone().ok_or_else(|| { syn::Error::new( sched.span, "scheduled tables must have a `#[primary_key] #[auto_inc] scheduled_id: u64` column", @@ -1133,6 +1202,105 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R .unzip(); let schedule = schedule.into_iter(); + if let Some(outbox) = &args.outbox { + if let Some(scheduled) = &args.scheduled { + return Err(syn::Error::new( + scheduled.span, + "outbox tables cannot also be scheduled tables", + )); + } + let primary_key_column = primary_key_column.as_ref().ok_or_else(|| { + syn::Error::new_spanned( + &outbox.receiver_reducer, + "outbox tables must have a `#[primary_key] #[auto_inc]` column of type `u64`", + ) + })?; + if !sequenced_columns + .iter() + .any(|col| col.index == primary_key_column.index) + { + return Err(syn::Error::new_spanned( + primary_key_column.ident, + "outbox tables must have a `#[primary_key] #[auto_inc]` column of type `u64`", + )); + } + } + + let mut outbox_arg_columns = None; + let outbox_typecheck = args + .outbox + .iter() + .map(|outbox| { + let receiver_reducer = &outbox.receiver_reducer; + let Some((target_column, _)) = &outbox_target_column else { + return Err(syn::Error::new_spanned( + receiver_reducer, + "outbox tables must have a `#[target]` column", + )); + }; + let target_ty = target_column.ty; + let mut receiver_module = receiver_reducer.clone(); + receiver_module.segments.pop(); + receiver_module.segments.pop_punct(); + if receiver_module.segments.is_empty() { + return Err(syn::Error::new_spanned( + receiver_reducer, + "outbox receiver reducer must be qualified, e.g. `outbox(receiver::reducer)`", + )); + } + let receiver_identity = quote!(<#receiver_reducer as spacetimedb::rt::OutboxReceiver>::Identity); + let mut receiver_outbox_typecheck = receiver_module.clone(); + let reducer_ident = receiver_reducer + .segments + .last() + .expect("receiver reducer path checked above") + .ident + .unraw(); + receiver_outbox_typecheck + .segments + .push(parse_quote!(__spacetimedb_typecheck_outbox)); + let reducer_name = receiver_outbox_typecheck + .segments + .last_mut() + .expect("receiver reducer path checked above"); + reducer_name.ident = format_ident!("__spacetimedb_typecheck_outbox_{}", reducer_ident); + let outbox_columns = columns.iter().map(|col| { + let ident = col.ident; + let ty = col.ty; + let param = Ident::new(&col.param_name, ident.span()); + let index = col.index; + quote!((#index, #ident: #ty as #param),) + }); + outbox_arg_columns = Some(quote! { + #receiver_outbox_typecheck!(#receiver_module, { #(#outbox_columns)* }) + }); + let primary_key_typecheck = primary_key_column.as_ref().map(|col| { + let ty = col.ty; + quote!(spacetimedb::rt::assert_outbox_table_primary_key::<#ty>();) + }); + Ok(quote! { + let _ = |x: #target_ty| { let _: #receiver_identity = x; }; + #primary_key_typecheck + }) + }) + .collect::>>()?; + + let outbox_desc = match (&args.outbox, &outbox_target_column) { + (Some(outbox), Some((target_column, _))) => { + let receiver_reducer = &outbox.receiver_reducer; + let target_col = target_column.index; + quote! { + const OUTBOX: Option> = Some(spacetimedb::table::OutboxDesc { + remote_reducer_name: <#receiver_reducer as spacetimedb::rt::OutboxReceiver>::NAME, + arg_columns: #outbox_arg_columns, + target_column: #target_col, + signature_hash: <#receiver_reducer as spacetimedb::rt::OutboxReceiver>::SIGNATURE_HASH, + }); + } + } + _ => quote! {}, + }; + let unique_err = if !unique_columns.is_empty() { quote!(spacetimedb::UniqueConstraintViolation) } else { @@ -1145,8 +1313,13 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R }; let field_types = fields.iter().map(|f| f.ty).collect::>(); - + let mutable_table_impl = args.outbox.is_none().then(|| { + quote! { + impl spacetimedb::table::MutableTable for #tablehandle_ident {} + } + }); let tabletype_impl = quote! { + #mutable_table_impl use spacetimedb::Serialize; impl spacetimedb::Table for #tablehandle_ident { type Row = #row_type; @@ -1166,8 +1339,10 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R #(const PRIMARY_KEY: Option = Some(#primary_col_id);)* const SEQUENCES: &'static [u16] = &[#(#sequence_col_ids),*]; #(const SCHEDULE: Option> = Some(#schedule);)* + #outbox_desc #table_id_from_name_func + fn __backend(&self) -> &spacetimedb::table::TableHandleBackend { &self.__backend } @@ -1337,6 +1512,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R const _: () = { #(let _ = <#field_types as spacetimedb::rt::TableColumn>::_ITEM;)* #schedule_typecheck + #(#outbox_typecheck)* #default_type_check }; diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index 6aeead3fb80..abde9cdce26 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -45,6 +45,7 @@ spacetimedb-auth = { workspace = true, optional = true } spacetimedb-test-datastore = { workspace = true, optional = true } [dev-dependencies] +spacetimedb-schema.workspace = true insta.workspace = true trybuild.workspace = true spacetimedb-lib = { path = "../lib", features = ["test"] } @@ -53,5 +54,9 @@ spacetimedb-lib = { path = "../lib", features = ["test"] } name = "test_utils" required-features = ["test-utils"] +[[test]] +name = "local_outbox" +required-features = ["test-utils"] + [lints] workspace = true diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index b946487cdc0..ab0b11622d0 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -6,7 +6,7 @@ use crate::{sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, S use spacetimedb_lib::bsatn::EncodeError; use spacetimedb_lib::db::raw_def::v10::{ CaseConversionPolicy, ExplicitNames as RawExplicitNames, RawEnvVarTypeV10, RawEnvironmentDeclarationV10, - RawModuleDefV10Builder, + RawModuleDefV10, RawModuleDefV10Builder, }; pub use spacetimedb_lib::db::raw_def::v9::Lifecycle as LifecycleReducer; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableType, ViewResultHeader}; @@ -170,6 +170,9 @@ pub trait FnInfo: ExplicitNames { /// Currently only views use this metadata. const VIEW_PRIMARY_KEY_COLUMNS: &'static [&'static str] = &[]; + /// The source name of the outbox table whose results this reducer handles. + const ON_RESULT_OUTBOX: Option<&'static str> = None; + /// The function to invoke. const INVOKE: Self::Invoke; @@ -180,6 +183,46 @@ pub trait FnInfo: ExplicitNames { } } +/// Metadata for a reducer handle generated from a remote module schema. +/// +/// Unlike FnInfo, this does not describe a locally exported callable and +/// therefore has no invocation function. It is used by module-side macros to +/// typecheck calls into another database. +pub trait RemoteReducer { + /// The destination identity type provided by the receiver bindings. + type Identity; + + /// The reducer name in the remote module schema. + const NAME: &'static str; + + /// Hash of the reducer signature as seen by the generated receiver bindings. + const SIGNATURE_HASH: &'static str; + + /// Reducer argument product, excluding the reducer context. + /// + /// Fields preserve the remote reducer's parameter product order, matching the + /// signature hash and positional BSATN arguments. + type Args; +} + +/// Metadata used by outboxes targeting generated or source-defined reducer handles. +pub trait OutboxReceiver { + /// The destination identity type accepted by this receiver. + type Identity; + + /// The registered reducer name, or its source name when resolved locally. + const NAME: &'static str; + + /// A precomputed remote signature, or `None` for host resolution of a local reducer. + const SIGNATURE_HASH: Option<&'static str>; +} + +impl OutboxReceiver for T { + type Identity = T::Identity; + const NAME: &'static str = T::NAME; + const SIGNATURE_HASH: Option<&'static str> = Some(T::SIGNATURE_HASH); +} + pub trait Procedure<'de, A: Args<'de>, Ret: IntoProcedureResult> { fn invoke(&self, ctx: &mut ProcedureContext, args: A) -> Ret; } @@ -499,6 +542,20 @@ where core::mem::forget(_x); } +/// Resolve an outbox accessor to its registered table source name. +pub const fn on_result_outbox(_table: fn(&crate::Local) -> T) -> &'static str { + T::TABLE_NAME +} + +/// Assert that an `on_result` reducer accepts the outbox row and delivery result. +pub fn on_result_typecheck(_f: F, _table: fn(&crate::Local) -> T) +where + T: crate::Table, + F: Fn(&ReducerContext, T::Row, Result<(), String>) -> Ret, + Ret: IntoReducerResult, +{ +} + /// Tacit marker argument to [`ExportFunctionForScheduledTable`] for reducers. pub struct FnKindReducer { _never: Infallible, @@ -577,6 +634,9 @@ impl TableColumn for T {} /// Assert that the primary_key column of a scheduled table is a u64. pub const fn assert_scheduled_table_primary_key() {} +/// Assert that the primary_key column of an outbox table is a u64. +pub const fn assert_outbox_table_primary_key() {} + mod sealed { pub trait Sealed {} } @@ -588,6 +648,13 @@ pub trait ScheduledTablePrimaryKey: sealed::Sealed {} impl sealed::Sealed for u64 {} impl ScheduledTablePrimaryKey for u64 {} +#[diagnostic::on_unimplemented( + message = "outbox table primary key must be a `u64`", + label = "should be `u64`, not `{Self}`" +)] +pub trait OutboxTablePrimaryKey: sealed::Sealed {} +impl OutboxTablePrimaryKey for u64 {} + /// Used in the last type parameter of `Reducer` to indicate that the /// context argument *should* be passed to the reducer logic. pub struct ContextArg; @@ -806,6 +873,29 @@ pub fn register_table() { table.finish(); + if let Some(outbox) = T::OUTBOX { + if let Some(hash) = outbox.signature_hash { + module.inner.add_outbox( + T::TABLE_NAME, + outbox.remote_reducer_name, + outbox.target_column, + outbox.arg_columns.iter().copied().map(Into::into), + None::<&str>, + spacetimedb_lib::Hash::from_hex(hash).expect("generated outbox signature hash must be valid hex"), + ); + } else { + // Only the source reference is exported. The host resolves names and + // computes the signature from its validated reducer definition. + module.inner.add_local_outbox( + T::TABLE_NAME, + outbox.remote_reducer_name, + outbox.target_column, + outbox.arg_columns.iter().copied().map(Into::into), + None::<&str>, + ); + } + } + module.inner.add_explicit_names(T::explicit_names()); }) } @@ -833,6 +923,9 @@ pub fn register_reducer<'a, A: Args<'a>, I: FnInfo>(_: impl } else { module.inner.add_reducer(I::NAME, params); } + if let Some(outbox_table) = I::ON_RESULT_OUTBOX { + module.on_result_reducers.push((outbox_table, I::NAME)); + } module.reducers.push(I::INVOKE); module.inner.add_explicit_names(I::explicit_names()); @@ -1114,11 +1207,22 @@ pub struct ModuleBuilder { views: Vec, /// The anonymous views of the module. views_anon: Vec, + on_result_reducers: Vec<(&'static str, &'static str)>, } // Not actually a mutex; because WASM is single-threaded this basically just turns into a refcell. static DESCRIBERS: Mutex>> = Mutex::new(Vec::new()); +impl ModuleBuilder { + fn finish_definition(&mut self) -> RawModuleDefV10 { + for (outbox_table, reducer) in self.on_result_reducers.drain(..) { + self.inner.set_outbox_on_result(outbox_table, reducer); + } + + std::mem::take(&mut self.inner).finish() + } +} + /// A reducer function takes in `(ReducerContext, Args)` /// and returns a result with a possible error message. pub type ReducerFn = fn(&ReducerContext, &[u8]) -> ReducerResult; @@ -1147,7 +1251,7 @@ pub fn module_def_for_tests() -> RawModuleDef { for describer in &mut *DESCRIBERS.lock().unwrap() { describer(&mut module) } - RawModuleDef::V10(module.inner.finish()) + RawModuleDef::V10(module.finish_definition()) } /// Called by the host when the module is initialized @@ -1174,7 +1278,7 @@ extern "C" fn __describe_module__(description: BytesSink) { } // Serialize the module to bsatn. - let module_def = module.inner.finish(); + let module_def = module.finish_definition(); let module_def = RawModuleDef::V10(module_def); let bytes = bsatn::to_vec(&module_def).expect("unable to serialize typespace"); diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index aaa678b40db..495816c263f 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -318,9 +318,15 @@ impl TableHandleBackend { } } +/// A table whose existing rows can be updated or deleted. +/// +/// The [`table`](macro@crate::table) macro implements this for ordinary tables. +/// Outbox tables are insert-only and do not implement this trait. +pub trait MutableTable: Table {} + /// Implemented for every `TableHandle` struct generated by the [`table`](macro@crate::table) macro. -/// Contains methods that are present for every table, regardless of what unique constraints -/// and indexes are present. +/// Provides common table operations, regardless of what unique constraints and indexes +/// are present. Deleting rows requires [`MutableTable`]. /// /// To get a `TableHandle` // TODO: should we rename this `TableHandle`? Documenting this, I think that's much clearer. @@ -437,7 +443,10 @@ pub trait Table: TableInternal + ExplicitNames { /// No analogue to auto-increment placeholders exists for deletions. /// /// May panic if deleting the row violates any constraints. - fn delete(&self, row: Self::Row) -> bool { + fn delete(&self, row: Self::Row) -> bool + where + Self: MutableTable, + { // Note that as of writing deletion is infallible, but future work may define new constraints, // e.g. foreign keys, which cause deletion to fail in some cases. // If and when these new constraints are added, @@ -454,7 +463,10 @@ pub trait Table: TableInternal + ExplicitNames { /// /// Returns the number of rows that were deleted, /// i.e., the value of [`self.count()`](Table::count) before this call. - fn clear(&self) -> u64 { + fn clear(&self) -> u64 + where + Self: MutableTable, + { sys::datastore_clear(Self::table_id()).expect("datastore_clear() call failed") } @@ -478,6 +490,7 @@ pub trait TableInternal: Sized { const PRIMARY_KEY: Option = None; const SEQUENCES: &'static [u16]; const SCHEDULE: Option> = None; + const OUTBOX: Option> = None; const IS_EVENT: bool = false; /// Returns the ID of this table. @@ -504,6 +517,15 @@ pub enum IndexAlgo<'a> { Direct { column: u16 }, } +pub struct OutboxDesc<'a> { + pub remote_reducer_name: &'a str, + /// Outbox column positions in remote reducer parameter order. + pub arg_columns: &'a [u16], + pub target_column: u16, + /// Generated bindings provide a hash; local reducers are resolved after module validation. + pub signature_hash: Option<&'a str>, +} + pub struct ScheduleDesc<'a> { pub reducer_or_procedure_name: &'a str, pub scheduled_at_column: u16, @@ -707,7 +729,10 @@ impl> UniqueColumn) -> bool { + pub fn delete(&self, col_val: impl Borrow) -> bool + where + Tbl: MutableTable, + { self._delete(col_val.borrow()).0 } @@ -747,6 +772,7 @@ impl> UniqueColumn Tbl::Row where + Tbl: MutableTable, Col: PrimaryKey, { if Tbl::IS_EVENT { @@ -771,7 +797,10 @@ impl> UniqueColumn Result> { + pub fn try_insert_or_update(&self, new_row: Tbl::Row) -> Result> + where + Tbl: MutableTable, + { let col_val = Col::get_field(&new_row); // If the row doesn't exist, delete will return false, which we ignore. let _ = self.delete(col_val); @@ -789,7 +818,10 @@ impl> UniqueColumn Tbl::Row { + pub fn insert_or_update(&self, new_row: Tbl::Row) -> Tbl::Row + where + Tbl: MutableTable, + { self.try_insert_or_update(new_row).unwrap_or_else(|e| panic!("{e}")) } } @@ -1036,6 +1068,7 @@ impl PointIndex /// though at present no such constraints exist. pub fn delete(&self, point: P) -> u64 where + Tbl: MutableTable, P: WithPointArg, { if Tbl::IS_EVENT { @@ -1374,6 +1407,7 @@ impl RangedIndex /// though at present no such constraints exist. pub fn delete(&self, b: B) -> u64 where + Tbl: MutableTable, B: IndexScanRangeBounds, { if Tbl::IS_EVENT { diff --git a/crates/bindings/tests/fixtures/idc_receiver/lib.rs b/crates/bindings/tests/fixtures/idc_receiver/lib.rs new file mode 100644 index 00000000000..802d3daba53 --- /dev/null +++ b/crates/bindings/tests/fixtures/idc_receiver/lib.rs @@ -0,0 +1,124 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] + +use spacetimedb::spacetimedb_lib as __lib; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct Identity(pub spacetimedb::Identity); + +impl spacetimedb::SpacetimeType for Identity { + fn make_type( + typespace: &mut S, + ) -> spacetimedb::spacetimedb_lib::AlgebraicType { + ::make_type(typespace) + } +} + +impl spacetimedb::Serialize for Identity { + fn serialize(&self, serializer: S) -> Result { + ::serialize(&self.0, serializer) + } +} + +impl<'de> spacetimedb::Deserialize<'de> for Identity { + fn deserialize>(deserializer: D) -> Result { + ::deserialize(deserializer).map(Self) + } +} + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ReceivePingArgs { + pub body: String, +} + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as body), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(body), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_receive_ping_body { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(body), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as body), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: String = x; }; + $module::__spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_receive_ping_body!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_receive_ping_body; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_receive_ping { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_receive_ping; + +#[allow(non_camel_case_types)] +pub struct receive_ping; + +impl spacetimedb::rt::RemoteReducer for receive_ping { + type Identity = Identity; + const NAME: &'static str = "wire_ping"; + const SIGNATURE_HASH: &'static str = "386957a94632958d5ae8343e258f3a6f9dce9d7dafc46831050894baa7a4e4e1"; + type Args = ReceivePingArgs; +} + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NoopArgs {} + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_noop { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_noop; + +#[allow(non_camel_case_types)] +pub struct noop; + +impl spacetimedb::rt::RemoteReducer for noop { + type Identity = Identity; + const NAME: &'static str = "noop"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = NoopArgs; +} diff --git a/crates/bindings/tests/local_outbox.rs b/crates/bindings/tests/local_outbox.rs new file mode 100644 index 00000000000..3029bc9d77b --- /dev/null +++ b/crates/bindings/tests/local_outbox.rs @@ -0,0 +1,54 @@ +#![allow(non_snake_case)] + +use spacetimedb::spacetimedb_lib::RawModuleDef; + +#[spacetimedb::settings] +const CASE_CONVERSION_POLICY: spacetimedb::CaseConversionPolicy = spacetimedb::CaseConversionPolicy::None; + +#[spacetimedb::reducer] +pub fn receivePing(_: &spacetimedb::ReducerContext, messageBody: String) { + let _ = messageBody; +} + +#[spacetimedb::table(accessor = outboundPing, outbox(crate::receivePing))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: spacetimedb::Identity, + #[param(name = messageBody)] + payload: String, +} + +#[spacetimedb::reducer(on_result(self::outboundPing))] +pub fn on_ping_result(_: &spacetimedb::ReducerContext, _row: OutboundPing, _result: Result<(), String>) {} + +#[test] +fn local_outbox_hash_uses_the_canonical_reducer_signature() { + let RawModuleDef::V10(raw) = spacetimedb::test_utils::module_def() else { + panic!("expected V10 module definition"); + }; + assert_eq!(raw.case_conversion_policy(), spacetimedb::CaseConversionPolicy::None); + assert!(raw.outboxes().is_none()); + let outbox = &raw.local_outboxes().unwrap()[0]; + assert_eq!(outbox.reducer_source_name.as_ref(), "receivePing"); + assert_eq!(outbox.on_result_reducer.as_ref().unwrap().as_ref(), "on_ping_result"); + assert_eq!(outbox.arg_columns.len(), 1); + + let module = spacetimedb_schema::def::ModuleDef::try_from(RawModuleDef::V10(raw)).unwrap(); + let reducer = module.reducer_by_name("receivePing").unwrap().1; + let outbox = module.tables().find_map(|table| table.outbox.as_ref()).unwrap(); + assert_eq!( + reducer.params().elements[0].name.as_ref().unwrap().as_ref(), + "messageBody" + ); + let expected_hash = spacetimedb_schema::def::reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + module.typespace(), + ); + assert_eq!(outbox.signature_hash, expected_hash); + assert_eq!(outbox.on_result_reducer.as_ref().unwrap().as_ref(), "on_ping_result"); +} diff --git a/crates/bindings/tests/local_outbox_ui/wrong_argument_type.rs b/crates/bindings/tests/local_outbox_ui/wrong_argument_type.rs new file mode 100644 index 00000000000..dc7db048c66 --- /dev/null +++ b/crates/bindings/tests/local_outbox_ui/wrong_argument_type.rs @@ -0,0 +1,17 @@ +#[spacetimedb::reducer] +pub fn receive_ping(_: &spacetimedb::ReducerContext, body: String) { + let _ = body; +} + +#[spacetimedb::table(accessor = wrong_argument_type, outbox(crate::receive_ping))] +pub struct WrongArgumentType { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: spacetimedb::Identity, + #[param(name = body)] + payload: u64, +} + +fn main() {} diff --git a/crates/bindings/tests/local_outbox_ui/wrong_argument_type.stderr b/crates/bindings/tests/local_outbox_ui/wrong_argument_type.stderr new file mode 100644 index 00000000000..5c7364fa7a8 --- /dev/null +++ b/crates/bindings/tests/local_outbox_ui/wrong_argument_type.stderr @@ -0,0 +1,16 @@ +error[E0308]: mismatched types + --> tests/local_outbox_ui/wrong_argument_type.rs:1:1 + | +1 | #[spacetimedb::reducer] + | ^^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `u64` +2 | pub fn receive_ping(_: &spacetimedb::ReducerContext, body: String) { + | ------ expected due to this +... +6 | #[spacetimedb::table(accessor = wrong_argument_type, outbox(crate::receive_ping))] + | ---------------------------------------------------------------------------------- in this attribute macro expansion + | + = note: this error originates in the macro `__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body` which comes from the expansion of the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) +help: try using a conversion method + | +1 | #[spacetimedb::reducer].to_string() + | ++++++++++++ diff --git a/crates/bindings/tests/pass/outbox_arg_columns.rs b/crates/bindings/tests/pass/outbox_arg_columns.rs new file mode 100644 index 00000000000..8a7261144f2 --- /dev/null +++ b/crates/bindings/tests/pass/outbox_arg_columns.rs @@ -0,0 +1,103 @@ +#[path = "../fixtures/idc_receiver/lib.rs"] +mod receiver; + +mod remote_bindings { + pub(crate) use crate::receiver; +} + +#[spacetimedb::table(accessor = outbound_ping, name = "WireOutbox", outbox(crate::remote_bindings::receiver::receive_ping))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + audit: String, + #[param(name = body)] + payload: String, + #[target] + destination: receiver::Identity, +} + +mod outboxes { + use super::receiver; + + #[spacetimedb::table(accessor = plain_ping, outbox(self::receiver::receive_ping))] + pub struct PlainPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + body: String, + } +} + +#[spacetimedb::table(accessor = empty_ping, outbox(receiver::noop))] +pub struct EmptyPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, +} + +#[spacetimedb::reducer(on_result(self::outbound_ping))] +pub fn on_ping_result(_: &spacetimedb::ReducerContext, _: OutboundPing, _: Result<(), String>) {} + +mod callbacks { + #[spacetimedb::reducer(name = "WireCallback", on_result(super::outboxes::plain_ping))] + pub fn on_plain_result(_: &spacetimedb::ReducerContext, _: super::outboxes::PlainPing, _: Result<(), String>) {} +} + +fn main() { + use spacetimedb::rt::FnInfo; + use spacetimedb::table::TableInternal; + + assert_eq!(on_ping_result::ON_RESULT_OUTBOX, Some("outbound_ping")); + assert_eq!(callbacks::on_plain_result::ON_RESULT_OUTBOX, Some("plain_ping")); + assert_eq!(outbound_ping__TableHandle::OUTBOX.unwrap().arg_columns, &[2]); + assert_eq!(outbound_ping__TableHandle::OUTBOX.unwrap().target_column, 3); + assert_eq!(outboxes::plain_ping__TableHandle::OUTBOX.unwrap().arg_columns, &[2]); + assert!(empty_ping__TableHandle::OUTBOX.unwrap().arg_columns.is_empty()); + assert_eq!( + outboxes::plain_ping__TableHandle::OUTBOX.unwrap().remote_reducer_name, + "wire_ping" + ); +} + +// Outbox tables retain inserts and reads, including primary-key lookups. +fn read_and_insert(ctx: &spacetimedb::ReducerContext, row: OutboundPing) { + use spacetimedb::Table; + let table = ctx.db.outbound_ping(); + let row = table.insert(row); + let _ = table.id().find(row.id); + let _ = table.try_insert(row); + let _ = table.count(); + let _ = table.iter(); +} + +#[spacetimedb::table( + accessor = ordinary, + index(accessor = by_value, btree(columns = [value])), + index(accessor = by_value_hash, hash(columns = [value])), +)] +pub struct Ordinary { + #[primary_key] + id: u64, + value: String, +} + +// Ordinary tables retain all update and delete APIs. +fn mutate_ordinary(ctx: &spacetimedb::ReducerContext, row: Ordinary) { + use spacetimedb::Table; + let table = ctx.db.ordinary(); + let row = table.id().update(row); + #[cfg(feature = "unstable")] + let row = table.id().insert_or_update(row); + #[cfg(feature = "unstable")] + let row = table.id().try_insert_or_update(row).unwrap(); + table.delete(row); + table.id().delete(1); + table.by_value().delete("value"); + table.by_value_hash().delete("value"); + table.clear(); +} diff --git a/crates/bindings/tests/pass/outbox_local.rs b/crates/bindings/tests/pass/outbox_local.rs new file mode 100644 index 00000000000..eeafdba2d5a --- /dev/null +++ b/crates/bindings/tests/pass/outbox_local.rs @@ -0,0 +1,38 @@ +#![allow(non_snake_case)] + +#[spacetimedb::settings] +const CASE_CONVERSION_POLICY: spacetimedb::CaseConversionPolicy = spacetimedb::CaseConversionPolicy::None; + +#[spacetimedb::reducer] +pub fn receivePing(_: &spacetimedb::ReducerContext, messageBody: String) {} + +#[spacetimedb::table(accessor = outboundPing, outbox(self::receivePing))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: spacetimedb::Identity, + #[param(name = messageBody)] + payload: String, +} + +mod nested { + #[spacetimedb::table(accessor = outbound_nested, outbox(super::receivePing))] + pub struct OutboundNested { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: spacetimedb::Identity, + #[param(name = messageBody)] + payload: String, + } +} + +fn main() { + use spacetimedb::table::TableInternal; + assert_eq!(outboundPing__TableHandle::OUTBOX.unwrap().arg_columns, &[2]); + assert_eq!(nested::outbound_nested__TableHandle::OUTBOX.unwrap().arg_columns, &[2]); + assert_eq!(::SIGNATURE_HASH, None); +} diff --git a/crates/bindings/tests/receiver_signatures.rs b/crates/bindings/tests/receiver_signatures.rs new file mode 100644 index 00000000000..830c509e7ae --- /dev/null +++ b/crates/bindings/tests/receiver_signatures.rs @@ -0,0 +1,204 @@ +use spacetimedb::spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, ExplicitNames, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, +}; +use spacetimedb::spacetimedb_lib::db::raw_def::v9::direct; +use spacetimedb::spacetimedb_lib::{AlgebraicType, ProductType}; +use spacetimedb_schema::def::{reducer_signature_hash, ModuleDef}; + +fn receiver_module(policy: CaseConversionPolicy, explicit_name: bool, unrelated_type: bool) -> RawModuleDefV10 { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(policy); + if unrelated_type { + builder.add_algebraic_type( + [], + "Unused", + AlgebraicType::product([("Value", AlgebraicType::Bool)]), + true, + ); + } + let state = builder.add_algebraic_type([], "State", AlgebraicType::unit(), true); + let operation = builder.add_algebraic_type( + [], + "StateOp", + AlgebraicType::sum([ + ("SetState", AlgebraicType::Ref(state)), + ("ClearState", AlgebraicType::unit()), + ]), + true, + ); + builder.add_reducer( + "receivePing", + ProductType::from([ + ("MessageBody", AlgebraicType::String), + ("StateOps", AlgebraicType::array(AlgebraicType::Ref(operation))), + ]), + ); + builder + .build_table_with_new_type( + "outboundPing", + ProductType::from([ + ("id", AlgebraicType::U64), + ("target", AlgebraicType::identity()), + ("MessageBody", AlgebraicType::String), + ("StateOps", AlgebraicType::array(AlgebraicType::Ref(operation))), + ]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index_no_accessor_name(direct(0), "outbound_ping_id_idx") + .finish(); + builder.add_local_outbox("outboundPing", "receivePing", 1, [2.into(), 3.into()], None::<&str>); + if explicit_name { + let mut names = ExplicitNames::default(); + names.insert_function("receivePing", "PreviousWireName"); + names.insert_function("receivePing", "WirePing"); + builder.add_explicit_names(names); + } + let mut raw = builder.finish(); + for section in &mut raw.sections { + if let RawModuleDefV10Section::Typespace(typespace) = section { + typespace[state] = AlgebraicType::product([ + ("ItemID", AlgebraicType::U64), + ("OwnerIdentity", AlgebraicType::identity()), + ("Notes", AlgebraicType::option(AlgebraicType::String)), + ("Children", AlgebraicType::array(AlgebraicType::Ref(state))), + ]); + } + } + raw +} + +#[test] +fn local_outbox_signatures_use_validated_reducers() { + for policy in [CaseConversionPolicy::None, CaseConversionPolicy::SnakeCase] { + for explicit_name in [false, true] { + let mut hashes = Vec::new(); + for unrelated_type in [false, true] { + let raw = receiver_module(policy, explicit_name, unrelated_type); + assert!(raw.outboxes().is_none()); + assert_eq!( + raw.local_outboxes().unwrap()[0].reducer_source_name.as_ref(), + "receivePing" + ); + let validated: ModuleDef = raw.try_into().unwrap(); + let reducer = validated + .reducers() + .find(|reducer| reducer.accessor_name.as_ref() == "receivePing") + .unwrap(); + let outbox = validated.tables().find_map(|table| table.outbox.as_ref()).unwrap(); + assert_eq!(outbox.remote_reducer, reducer.name); + assert_eq!( + outbox.signature_hash, + reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + validated.typespace(), + ) + ); + hashes.push(outbox.signature_hash); + // Extracted schemas contain resolved remote-style metadata, so receiver + // codegen and a second validation do not need the source reference. + let exported: RawModuleDefV10 = validated.clone().into(); + assert!(exported.local_outboxes().is_none()); + let roundtrip: ModuleDef = exported.try_into().unwrap(); + assert_eq!( + roundtrip.tables().find_map(|table| table.outbox.as_ref()).unwrap(), + outbox + ); + } + assert_eq!(hashes[0], hashes[1]); + } + } +} + +#[test] +fn local_outbox_signature_preserves_existing_wire_hash() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer("receivePing", ProductType::from([("Body", AlgebraicType::String)])); + builder + .build_table_with_new_type( + "outboundPing", + ProductType::from([ + ("id", AlgebraicType::U64), + ("target", AlgebraicType::identity()), + ("Body", AlgebraicType::String), + ]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index_no_accessor_name(direct(0), "outbound_ping_id_idx") + .finish(); + builder.add_local_outbox("outboundPing", "receivePing", 1, [2.into()], None::<&str>); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let outbox = module.tables().find_map(|table| table.outbox.as_ref()).unwrap(); + assert_eq!(outbox.remote_reducer.as_ref(), "receive_ping"); + assert_eq!( + outbox.signature_hash.to_hex().to_string(), + "386957a94632958d5ae8343e258f3a6f9dce9d7dafc46831050894baa7a4e4e1" + ); +} + +#[test] +fn local_outbox_requires_a_reducer_source_name() { + let mut raw = receiver_module(CaseConversionPolicy::SnakeCase, true, false); + for section in &mut raw.sections { + if let RawModuleDefV10Section::LocalOutboxes(outboxes) = section { + // An explicit canonical name is not a valid source reference. + outboxes[0].reducer_source_name = "WirePing".into(); + } + } + let errors = ModuleDef::try_from(raw).unwrap_err(); + assert!( + errors.to_string().contains("local receiver reducer WirePing"), + "{errors}" + ); +} + +#[test] +fn local_and_remote_outboxes_cannot_share_a_table() { + let mut raw = receiver_module(CaseConversionPolicy::None, false, false); + raw.sections.push(RawModuleDefV10Section::Outboxes(vec![ + spacetimedb::spacetimedb_lib::db::raw_def::v10::RawOutboxDefV10 { + table_name: "outboundPing".into(), + remote_reducer: "remote_ping".into(), + target_column: 1.into(), + arg_columns: vec![2.into(), 3.into()], + on_result_reducer: None, + signature_hash: spacetimedb::spacetimedb_lib::Hash::ZERO, + }, + ])); + let errors = ModuleDef::try_from(raw).unwrap_err(); + assert!(errors.to_string().contains("assigned in multiple outboxes"), "{errors}"); +} + +#[test] +fn local_outbox_receiver_follows_its_submodule_namespace() { + let child = receiver_module(CaseConversionPolicy::None, true, false); + let mut middle = RawModuleDefV10Builder::new(); + middle.add_submodule("innerMount", child); + let mut names = ExplicitNames::default(); + names.insert_namespace("innerMount", "InnerWire"); + middle.add_explicit_names(names); + let mut root = RawModuleDefV10Builder::new(); + root.add_submodule("outerLib", middle.finish()); + let mut module: ModuleDef = root.finish().try_into().unwrap(); + + for _ in 0..3 { + let child = &module.submodules()["outer_lib"].submodules()["InnerWire"]; + let outbox = child.tables().find_map(|table| table.outbox.as_ref()).unwrap(); + assert_eq!(outbox.remote_reducer.as_ref(), "outer_lib.InnerWire.WirePing"); + let reducer = module.reducer_by_name(&outbox.remote_reducer).unwrap().1; + assert_eq!( + outbox.signature_hash, + reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + child.typespace(), + ) + ); + let raw: RawModuleDefV10 = module.into(); + module = raw.try_into().unwrap(); + } +} diff --git a/crates/bindings/tests/snapshots/deps__duplicate_deps.snap b/crates/bindings/tests/snapshots/deps__duplicate_deps.snap index 4f2ea4cc313..b0cf8861b0a 100644 --- a/crates/bindings/tests/snapshots/deps__duplicate_deps.snap +++ b/crates/bindings/tests/snapshots/deps__duplicate_deps.snap @@ -1,6 +1,6 @@ --- source: crates/bindings/tests/deps.rs -expression: cargo tree -p spacetimedb -e no-dev -d --depth 0 +expression: cargo tree -p spacetimedb -e no-dev --color never --target wasm32-unknown-unknown -d --depth 0 --- heck v0.4.1 diff --git a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap index e344e1fa4f2..dffa3abdfb1 100644 --- a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap +++ b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap @@ -1,6 +1,5 @@ --- source: crates/bindings/tests/deps.rs -assertion_line: 16 expression: "cargo tree -p spacetimedb -e no-dev --color never --target wasm32-unknown-unknown -f {lib}" --- total crates: 72 diff --git a/crates/bindings/tests/ui.rs b/crates/bindings/tests/ui.rs index c13cfdfaeb2..674fffef3d5 100644 --- a/crates/bindings/tests/ui.rs +++ b/crates/bindings/tests/ui.rs @@ -4,3 +4,9 @@ fn ui() { t.compile_fail("tests/ui/*.rs"); t.pass("tests/pass/*.rs"); } + +#[test] +fn local_outbox_typecheck() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/local_outbox_ui/*.rs"); +} diff --git a/crates/bindings/tests/ui/outbox_mutation.rs b/crates/bindings/tests/ui/outbox_mutation.rs new file mode 100644 index 00000000000..3a2cc5c3447 --- /dev/null +++ b/crates/bindings/tests/ui/outbox_mutation.rs @@ -0,0 +1,42 @@ +use spacetimedb::Table; + +#[path = "../fixtures/idc_receiver/lib.rs"] +mod receiver; + +#[spacetimedb::table( + accessor = outbound_ping, + outbox(receiver::receive_ping), + index(accessor = by_body, btree(columns = [body])), + index(accessor = by_body_hash, hash(columns = [body])), +)] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + body: String, +} + +fn mutate(ctx: &spacetimedb::ReducerContext, row: OutboundPing) { + let table = ctx.db.outbound_ping(); + table.clear(); + table.id().delete(1); + table.by_body().delete("body"); + table.by_body_hash().delete("body"); + table.id().update(row); +} + +fn delete(ctx: &spacetimedb::ReducerContext, row: OutboundPing) { + ctx.db.outbound_ping().delete(row); +} + +fn upsert(ctx: &spacetimedb::ReducerContext, row: OutboundPing) { + ctx.db.outbound_ping().id().insert_or_update(row); +} + +fn try_upsert(ctx: &spacetimedb::ReducerContext, row: OutboundPing) { + let _ = ctx.db.outbound_ping().id().try_insert_or_update(row); +} + +fn main() {} diff --git a/crates/bindings/tests/ui/outbox_mutation.stderr b/crates/bindings/tests/ui/outbox_mutation.stderr new file mode 100644 index 00000000000..366702daf7e --- /dev/null +++ b/crates/bindings/tests/ui/outbox_mutation.stderr @@ -0,0 +1,175 @@ +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:23:11 + | +23 | table.clear(); + | ^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `clear` + --> src/table.rs + | + | fn clear(&self) -> u64 + | ----- required by a bound in this associated function + | where + | Self: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `Table::clear` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:24:16 + | +24 | table.id().delete(1); + | ^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `UniqueColumn::::ColType, Col>::delete` + --> src/table.rs + | + | pub fn delete(&self, col_val: impl Borrow) -> bool + | ------ required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `UniqueColumn::::ColType, Col>::delete` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:25:21 + | +25 | table.by_body().delete("body"); + | ^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `RangedIndex::::delete` + --> src/table.rs + | + | pub fn delete(&self, b: B) -> u64 + | ------ required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `RangedIndex::::delete` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:26:26 + | +26 | table.by_body_hash().delete("body"); + | ^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `PointIndex::::delete` + --> src/table.rs + | + | pub fn delete(&self, point: P) -> u64 + | ------ required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `PointIndex::::delete` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:27:16 + | +27 | table.id().update(row); + | ^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `UniqueColumn::::ColType, Col>::update` + --> src/table.rs + | + | pub fn update(&self, new_row: Tbl::Row) -> Tbl::Row + | ------ required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `UniqueColumn::::ColType, Col>::update` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:31:28 + | +31 | ctx.db.outbound_ping().delete(row); + | ^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `spacetimedb::Table::delete` + --> src/table.rs + | + | fn delete(&self, row: Self::Row) -> bool + | ------ required by a bound in this associated function + | where + | Self: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `Table::delete` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:35:33 + | +35 | ctx.db.outbound_ping().id().insert_or_update(row); + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `UniqueColumn::::ColType, Col>::insert_or_update` + --> src/table.rs + | + | pub fn insert_or_update(&self, new_row: Tbl::Row) -> Tbl::Row + | ---------------- required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `UniqueColumn::::ColType, Col>::insert_or_update` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied + --> tests/ui/outbox_mutation.rs:39:41 + | +39 | let _ = ctx.db.outbound_ping().id().try_insert_or_update(row); + | ^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `spacetimedb::table::MutableTable` is not implemented for `outbound_ping__TableHandle` + --> tests/ui/outbox_mutation.rs:6:1 + | + 6 | / #[spacetimedb::table( + 7 | | accessor = outbound_ping, + | |____________________________^ +note: required by a bound in `UniqueColumn::::ColType, Col>::try_insert_or_update` + --> src/table.rs + | + | pub fn try_insert_or_update(&self, new_row: Tbl::Row) -> Result> + | -------------------- required by a bound in this associated function + | where + | Tbl: MutableTable, + | ^^^^^^^^^^^^ required by this bound in `UniqueColumn::::ColType, Col>::try_insert_or_update` + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/bindings/tests/ui/outbox_receiver_typecheck.rs b/crates/bindings/tests/ui/outbox_receiver_typecheck.rs new file mode 100644 index 00000000000..55cb1e0d732 --- /dev/null +++ b/crates/bindings/tests/ui/outbox_receiver_typecheck.rs @@ -0,0 +1,56 @@ +#[path = "../fixtures/idc_receiver/lib.rs"] +mod receiver; +#[spacetimedb::table(accessor = outbound_ping, outbox(receiver::receive_ping))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + body: String, + #[target] + target: u8, +} + +#[spacetimedb::table(accessor = wrong_body, outbox(receiver::receive_ping))] +pub struct WrongBody { + #[primary_key] + #[auto_inc] + id: u64, + body: u64, + #[target] + target: receiver::Identity, +} + +#[spacetimedb::table(accessor = missing_body, outbox(receiver::receive_ping))] +pub struct MissingBody { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + payload: String, +} + +#[spacetimedb::table(accessor = duplicate_body, outbox(receiver::receive_ping))] +pub struct DuplicateBody { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + body: String, + #[param(name = body)] + payload: String, +} + +#[spacetimedb::table(accessor = canonical_param_name, outbox(receiver::receive_ping))] +pub struct CanonicalParamName { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + #[param(name = Body)] + payload: String, +} + +fn main() {} diff --git a/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr new file mode 100644 index 00000000000..7d0e4512f20 --- /dev/null +++ b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr @@ -0,0 +1,77 @@ +error: outbox has no column matching remote reducer parameter `body` + --> tests/ui/../fixtures/idc_receiver/lib.rs + | + | / compile_error!(concat!( + | | "outbox has no column matching remote reducer parameter `", + | | stringify!(body), + | | "`" + | | )); + | |__________^ + | + ::: tests/ui/outbox_receiver_typecheck.rs:45:1 + | +45 | #[spacetimedb::table(accessor = canonical_param_name, outbox(receiver::receive_ping))] + | -------------------------------------------------------------------------------------- in this attribute macro expansion + | + = note: this error originates in the macro `__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body` which comes from the expansion of the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: outbox has multiple columns matching remote reducer parameter `body` + --> tests/ui/../fixtures/idc_receiver/lib.rs + | + | / compile_error!(concat!( + | | "outbox has multiple columns matching remote reducer parameter `", + | | stringify!(body), + | | "`" + | | )); + | |__________^ + | + ::: tests/ui/outbox_receiver_typecheck.rs:33:1 + | +33 | #[spacetimedb::table(accessor = duplicate_body, outbox(receiver::receive_ping))] + | -------------------------------------------------------------------------------- in this attribute macro expansion + | + = note: this error originates in the macro `__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate` which comes from the expansion of the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: outbox has no column matching remote reducer parameter `body` + --> tests/ui/../fixtures/idc_receiver/lib.rs + | + | / compile_error!(concat!( + | | "outbox has no column matching remote reducer parameter `", + | | stringify!(body), + | | "`" + | | )); + | |__________^ + | + ::: tests/ui/outbox_receiver_typecheck.rs:23:1 + | +23 | #[spacetimedb::table(accessor = missing_body, outbox(receiver::receive_ping))] + | ------------------------------------------------------------------------------ in this attribute macro expansion + | + = note: this error originates in the macro `__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body` which comes from the expansion of the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0308]: mismatched types + --> tests/ui/outbox_receiver_typecheck.rs:3:1 + | +3 | #[spacetimedb::table(accessor = outbound_ping, outbox(receiver::receive_ping))] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Identity`, found `u8` + | + = note: this error originates in the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0308]: mismatched types + --> tests/ui/../fixtures/idc_receiver/lib.rs + | + | let _ = |x: $ty| { let _: String = x; }; + | ------ ^ expected `String`, found `u64` + | | + | expected due to this + | + ::: tests/ui/outbox_receiver_typecheck.rs:13:1 + | +13 | #[spacetimedb::table(accessor = wrong_body, outbox(receiver::receive_ping))] + | ---------------------------------------------------------------------------- in this attribute macro expansion + | + = note: this error originates in the macro `__outbox_receiver::__spacetimedb_typecheck_outbox_receive_ping_body` which comes from the expansion of the attribute macro `spacetimedb::table` (in Nightly builds, run with -Z macro-backtrace for more info) +help: try using a conversion method + | +66 | let _ = |x: $ty| { let _: String = x.to_string(); }; + | ++++++++++++ diff --git a/crates/bindings/tests/ui/outbox_table_shape.rs b/crates/bindings/tests/ui/outbox_table_shape.rs new file mode 100644 index 00000000000..7a79dd71aec --- /dev/null +++ b/crates/bindings/tests/ui/outbox_table_shape.rs @@ -0,0 +1,53 @@ +#[path = "../fixtures/idc_receiver/lib.rs"] +mod receiver; +#[spacetimedb::table(accessor = missing_pk, outbox(receiver::receive_ping))] +pub struct MissingPk { + #[target] + target: receiver::Identity, + body: String, +} + +#[spacetimedb::table(accessor = non_auto_inc_pk, outbox(receiver::receive_ping))] +pub struct NonAutoIncPk { + #[primary_key] + id: u64, + #[target] + target: receiver::Identity, + body: String, +} + +#[spacetimedb::table(accessor = wrong_pk_type, outbox(receiver::receive_ping))] +pub struct WrongPkType { + #[primary_key] + #[auto_inc] + id: u32, + #[target] + target: receiver::Identity, + body: String, +} + +#[spacetimedb::table(accessor = scheduled_and_outbox, scheduled(scheduled), outbox(receiver::receive_ping))] +pub struct ScheduledAndOutbox { + #[primary_key] + #[auto_inc] + id: u64, + scheduled_at: spacetimedb::ScheduleAt, + #[target] + target: receiver::Identity, + body: String, +} + +#[spacetimedb::table(accessor = callback_outbox, outbox(receiver::receive_ping))] +pub struct CallbackOutbox { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: receiver::Identity, + body: String, +} + +#[spacetimedb::reducer(on_result(callback_outbox))] +pub fn bad_on_result(_ctx: &spacetimedb::ReducerContext, _row: CallbackOutbox) {} + +fn main() {} diff --git a/crates/bindings/tests/ui/outbox_table_shape.stderr b/crates/bindings/tests/ui/outbox_table_shape.stderr new file mode 100644 index 00000000000..5e89e8a6be4 --- /dev/null +++ b/crates/bindings/tests/ui/outbox_table_shape.stderr @@ -0,0 +1,55 @@ +error: outbox tables must have a `#[primary_key] #[auto_inc]` column of type `u64` + --> tests/ui/outbox_table_shape.rs:3:52 + | +3 | #[spacetimedb::table(accessor = missing_pk, outbox(receiver::receive_ping))] + | ^^^^^^^^^^^^^^^^^^^^^^ + +error: outbox tables must have a `#[primary_key] #[auto_inc]` column of type `u64` + --> tests/ui/outbox_table_shape.rs:13:5 + | +13 | id: u64, + | ^^ + +error: outbox tables cannot also be scheduled tables + --> tests/ui/outbox_table_shape.rs:29:55 + | +29 | #[spacetimedb::table(accessor = scheduled_and_outbox, scheduled(scheduled), outbox(receiver::receive_ping))] + | ^^^^^^^^^ + +error[E0277]: outbox table primary key must be a `u64` + --> tests/ui/outbox_table_shape.rs:23:9 + | +23 | id: u32, + | ^^^ should be `u64`, not `u32` + | + = help: the trait `spacetimedb::rt::OutboxTablePrimaryKey` is not implemented for `u32` +help: the trait `spacetimedb::rt::OutboxTablePrimaryKey` is implemented for `u64` + --> src/rt.rs + | + | impl OutboxTablePrimaryKey for u64 {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `spacetimedb::rt::assert_outbox_table_primary_key` + --> src/rt.rs + | + | pub const fn assert_outbox_table_primary_key() {} + | ^^^^^^^^^^^^^^^^^^^^^ required by this bound in `assert_outbox_table_primary_key` + +error[E0593]: function is expected to take 3 arguments, but it takes 2 arguments + --> tests/ui/outbox_table_shape.rs:51:8 + | +50 | #[spacetimedb::reducer(on_result(callback_outbox))] + | --------------------------------------------------- required by a bound introduced by this call +51 | pub fn bad_on_result(_ctx: &spacetimedb::ReducerContext, _row: CallbackOutbox) {} + | -------^^^^^^^^^^^^^---------------------------------------------------------- + | | | + | | expected function that takes 3 arguments + | takes 2 arguments + | +note: required by a bound in `spacetimedb::rt::on_result_typecheck` + --> src/rt.rs + | + | pub fn on_result_typecheck(_f: F, _table: fn(&crate::Local) -> T) + | ------------------- required by a bound in this function +... + | F: Fn(&ReducerContext, T::Row, Result<(), String>) -> Ret, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `on_result_typecheck` diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index a92f4bb1ff0..c0d517a6456 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -6,8 +6,8 @@ use clap::Arg; use clap::ArgAction::{Set, SetTrue}; use fs_err as fs; use spacetimedb_codegen::{ - generate, private_table_names, CodegenOptions, CodegenVisibility, Csharp, ImportExtension, Lang, OutputFile, Rust, - TypeScript, UnrealCpp, AUTO_GENERATED_PREFIX, + generate, generate_receiver_bindings, private_table_names, CodegenOptions, CodegenVisibility, Csharp, + ImportExtension, Lang, OutputFile, ReceiverLanguage, Rust, TypeScript, UnrealCpp, AUTO_GENERATED_PREFIX, }; use spacetimedb_lib::de::serde::DeserializeWrapper; use spacetimedb_lib::RawModuleDef; @@ -192,9 +192,18 @@ pub(super) fn dependency_generate_entries( .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("database dependencies require the sender to define `module-path`"))?; + let mut names = BTreeSet::new(); dependencies .iter() - .map(|dependency| dependency_generate_entry(dependency, sender_module_path, all_targets)) + .map(|dependency| { + if let Some(name) = dependency.get("name").and_then(|name| name.as_str()) { + anyhow::ensure!( + names.insert(name), + "duplicate dependency name {name:?} in module {sender_module_path:?}" + ); + } + dependency_generate_entry(dependency, sender_module_path, all_targets) + }) .collect() } @@ -210,6 +219,12 @@ fn get_module_source(fields: &HashMap) -> Option<(&st .find_map(|key| fields.get(key).cloned().map(|value| (key, value))) } +/// Reject paths and other invalid identifiers before resolving sources or writing files. +fn validate_dependency_name(name: &str) -> anyhow::Result<()> { + spacetimedb_schema::identifier::validate_identifier(name) + .with_context(|| format!("invalid dependency name {name:?}")) +} + fn dependency_generate_entry( dependency: &serde_json::Value, sender_module_path: &str, @@ -222,6 +237,7 @@ fn dependency_generate_entry( .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("dependency entries require `name`"))?; + validate_dependency_name(name)?; let (source_key, source_value, build_options, dotnet_version) = if let Some(path) = dependency.get("module-path") { ( @@ -453,6 +469,9 @@ fn prepare_generate_run_configs<'a>( let js_file = command_config.get_resolved_path("js_file", config_dir)?; let sender_module_path = command_config.get_resolved_path("sender_module_path", config_dir)?; let dependency_name = command_config.get_one::("dependency_name")?; + if let Some(name) = &dependency_name { + validate_dependency_name(name)?; + } let requested_lang = command_config.get_one::("language")?; let namespace = command_config .get_one::("namespace")? @@ -647,28 +666,54 @@ pub fn build_generate_entry( entry } -fn generate_receiver_bindings(_module: &ModuleDef, run: &GenerateRunConfig) -> anyhow::Result> { - // Each implementation receives the receiver schema and resolved sender options, - // including the dependency name and output directory, for its output layout. - match run.lang { - Language::Rust => { - // TODO(idc-rust): Generate Rust receiver types and reducer handles, including dependency modules. - anyhow::bail!("Rust receiver binding generation is not implemented yet") - } - Language::TypeScript => { - // TODO(idc-typescript): Generate TypeScript receiver types, reducer handles, and dependency exports. - anyhow::bail!("TypeScript receiver binding generation is not implemented yet") - } - Language::Csharp => { - // TODO(idc-csharp): Generate C# receiver types and reducer handles in the configured namespace. - anyhow::bail!("C# receiver binding generation is not implemented yet") - } - Language::Cpp => { - // TODO(idc-cpp): Generate C++ receiver types and reducer handles with the required headers. - anyhow::bail!("C++ receiver binding generation is not implemented yet") - } - Language::UnrealCpp => anyhow::bail!("Unreal C++ is a client binding target"), +fn generate_dependency_module_files(files: Vec, dependency_name: &str) -> Vec { + files + .into_iter() + .map(|OutputFile { filename, code }| { + let filename = if filename == "lib.rs" { + format!("{dependency_name}/mod.rs") + } else { + format!("{dependency_name}/{filename}") + }; + OutputFile { filename, code } + }) + .collect() +} + +fn write_remote_bindings_mod(out_dir: &Path, dependency_name: &str) -> anyhow::Result<()> { + fs::create_dir_all(out_dir)?; + let path = out_dir.join("mod.rs"); + let module_decl = format!("pub mod {dependency_name};"); + + let mut module_decls = if path.exists() { + fs::read_to_string(&path)? + .lines() + .filter(|line| line.starts_with("pub mod ")) + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + }; + if !module_decls.iter().any(|line| line == &module_decl) { + module_decls.push(module_decl); + module_decls.sort(); + } + + let mut lines = vec![ + AUTO_GENERATED_PREFIX.to_string(), + "// WILL NOT BE SAVED. MODIFY DEPENDENCIES IN SPACETIME.JSON INSTEAD.".to_string(), + String::new(), + "#![allow(unused, clippy::all)]".to_string(), + String::new(), + ]; + lines.extend(module_decls); + + let code = format!("{}\n", lines.join("\n")); + if !path.exists() || fs::read_to_string(&path)? != code { + println!("Writing file {}", path.display()); + fs::write(path, code)?; } + Ok(()) } pub async fn run_prepared_generate_configs( @@ -732,7 +777,24 @@ pub async fn run_prepared_generate_configs( } let generated_files = if run.bindings == BindingKind::Module { - generate_receiver_bindings(&module, &run)? + let lang = match run.lang { + Language::Rust => ReceiverLanguage::Rust, + Language::TypeScript => ReceiverLanguage::TypeScript, + Language::Csharp => ReceiverLanguage::Csharp { + namespace: &run.namespace, + }, + Language::Cpp => ReceiverLanguage::Cpp, + Language::UnrealCpp => anyhow::bail!("Unreal C++ is a client binding target"), + }; + let files = generate_receiver_bindings(&module, lang, run.dependency_name.as_deref())?; + if run.lang == Language::Rust + && let Some(name) = &run.dependency_name + { + write_remote_bindings_mod(&run.out_dir, name)?; + generate_dependency_module_files(files, name) + } else { + files + } } else { let csharp_lang; let unreal_cpp_lang; @@ -1417,7 +1479,7 @@ mod tests { } #[tokio::test] - async fn test_receiver_dependencies_prepare_options_and_reach_language_placeholders() { + async fn test_receiver_dependencies_prepare_options_and_generate_supported_languages() { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); let matches = cmd.clone().get_matches_from(vec!["generate", "sender"]); @@ -1461,7 +1523,7 @@ mod tests { assert_eq!(run.out_dir, sender.join(default_out_dir)); let out_dir = run.out_dir.clone(); - let error = run_prepared_generate_configs( + let result = run_prepared_generate_configs( runs, |path| { assert!(path.is_absolute()); @@ -1474,16 +1536,21 @@ mod tests { false, false, ) - .await - .unwrap_err(); - assert_eq!( - error.to_string(), - format!( - "{} receiver binding generation is not implemented yet", - lang.display_name() - ) - ); - assert!(!out_dir.exists(), "unimplemented generation must not write output"); + .await; + if lang == Language::Rust { + result.unwrap(); + assert!(out_dir.join("mod.rs").exists()); + assert!(out_dir.join("receiver/mod.rs").exists()); + } else { + assert_eq!( + result.unwrap_err().to_string(), + format!( + "{} receiver binding generation is not implemented yet", + lang.display_name() + ) + ); + assert!(!out_dir.exists(), "unimplemented generation must not write output"); + } } } diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index b7311745d89..a88919def4a 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -95,6 +95,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result { + Rust, + TypeScript, + Csharp { namespace: &'a str }, + Cpp, +} + +/// Generate receiver binding files without reading or writing the filesystem. +/// `dependency_name` scopes generated type names across receiver dependencies. +/// Use `None` for standalone bindings. +pub fn generate_receiver_bindings( + module: &ModuleDef, + lang: ReceiverLanguage<'_>, + dependency_name: Option<&str>, +) -> anyhow::Result> { + match lang { + ReceiverLanguage::Rust => Ok(rust::generate_rust_module_bindings(module, dependency_name)), + ReceiverLanguage::TypeScript => { + // TODO(idc-typescript): Generate TypeScript receiver types, reducer handles, and dependency exports. + anyhow::bail!("TypeScript receiver binding generation is not implemented yet") + } + ReceiverLanguage::Csharp { .. } => { + // TODO(idc-csharp): Generate C# receiver types and reducer handles in the configured namespace. + anyhow::bail!("C# receiver binding generation is not implemented yet") + } + ReceiverLanguage::Cpp => { + // TODO(idc-cpp): Generate C++ receiver types and reducer handles with the required headers. + anyhow::bail!("C++ receiver binding generation is not implemented yet") + } + } +} + pub fn generate(module: &ModuleDef, lang: &dyn Lang, options: &CodegenOptions) -> Vec { itertools::chain!( util::iter_tables(module, options.visibility).map(|tbl| lang.generate_table_file(module, tbl)), diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 5a0f829c636..f435d7d330f 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -27,6 +27,20 @@ const INDENT: &str = " "; pub struct Rust; +pub(super) fn generate_rust_module_bindings(module: &ModuleDef, dependency_name: Option<&str>) -> Vec { + // TODO: Generate IDC bindings for submodules. Only root types and reducers are emitted for now. + let type_refs = module_binding_reducer_type_refs(module); + itertools::chain!( + iter_module_binding_types(module, &type_refs).map(|typ| generate_module_binding_type_file( + module, + typ, + dependency_name + )), + [generate_module_bindings_root_file(module, &type_refs)], + ) + .collect() +} + impl Lang for Rust { fn generate_type_files(&self, module: &ModuleDef, typ: &TypeDef) -> Vec { let type_name = collect_case(Case::Pascal, typ.accessor_name.name_segments()); @@ -41,7 +55,7 @@ impl Lang for Rust { AlgebraicTypeDef::Product(product) => { gen_and_print_imports(module, out, &product.elements, &[typ.ty]); out.newline(); - define_struct_for_product(module, out, &type_name, &product.elements, "pub"); + define_struct_for_product(module, out, &type_name, &product.elements, "pub", TypeContext::Client); } AlgebraicTypeDef::Sum(sum) => { gen_and_print_imports(module, out, &sum.variants, &[typ.ty]); @@ -513,6 +527,7 @@ pub(super) fn parse_table_update( &args_type, &reducer.params_for_generate.elements, "pub(super)", + TypeContext::Client, ); out.newline(); @@ -645,6 +660,7 @@ impl {func_name} for super::RemoteReducers {{ &procedure.params_for_generate.elements, // non-pub visibility. "", + TypeContext::Client, ); out.newline(); @@ -709,7 +725,7 @@ impl {func_name} for super::RemoteProcedures {{ out.newline(); // Declare `pub mod` for each of the files generated. - print_module_decls(module, options.visibility, out); + print_module_decls(iter_module_names(module, options.visibility), out); out.newline(); @@ -981,53 +997,92 @@ fn implement_query_view_call_accessor( ) } +#[derive(Clone, Copy)] +enum TypeContext { + Client, + Receiver, + OutboxMacro, +} + pub fn write_type(module: &ModuleDef, out: &mut W, ty: &AlgebraicTypeUse) -> fmt::Result { + write_type_in_context(module, out, ty, TypeContext::Client) +} + +fn write_type_in_context( + module: &ModuleDef, + out: &mut W, + ty: &AlgebraicTypeUse, + context: TypeContext, +) -> fmt::Result { + let (sdk_path, sats_path, identity_type, type_prefix) = match context { + TypeContext::Client => ("__sdk", "__sats", "__sdk::Identity", ""), + TypeContext::Receiver => ("spacetimedb", "spacetimedb::sats", "Identity", ""), + TypeContext::OutboxMacro => ("::spacetimedb", "::spacetimedb::sats", "$module::Identity", "$module::"), + }; + // Macro bodies resolve types in the caller's scope. Qualify their builtins + // while preserving the established spelling in ordinary generated definitions. + let qualify_builtins = match context { + TypeContext::Client | TypeContext::Receiver => false, + TypeContext::OutboxMacro => true, + }; + let write_builtin = |out: &mut W, path: &str, name: &str| -> fmt::Result { + if qualify_builtins { + write!(out, "{path}::")?; + } + write!(out, "{name}") + }; match ty { AlgebraicTypeUse::Unit => write!(out, "()")?, - AlgebraicTypeUse::Never => write!(out, "std::convert::Infallible")?, - AlgebraicTypeUse::Identity => write!(out, "__sdk::Identity")?, - AlgebraicTypeUse::ConnectionId => write!(out, "__sdk::ConnectionId")?, - AlgebraicTypeUse::Timestamp => write!(out, "__sdk::Timestamp")?, - AlgebraicTypeUse::TimeDuration => write!(out, "__sdk::TimeDuration")?, - AlgebraicTypeUse::Uuid => write!(out, "__sdk::Uuid")?, - AlgebraicTypeUse::ScheduleAt => write!(out, "__sdk::ScheduleAt")?, + AlgebraicTypeUse::Never => { + let root = if qualify_builtins { "::core" } else { "std" }; + write!(out, "{root}::convert::Infallible")?; + } + AlgebraicTypeUse::Identity => write!(out, "{identity_type}")?, + AlgebraicTypeUse::ConnectionId => write!(out, "{sdk_path}::ConnectionId")?, + AlgebraicTypeUse::Timestamp => write!(out, "{sdk_path}::Timestamp")?, + AlgebraicTypeUse::TimeDuration => write!(out, "{sdk_path}::TimeDuration")?, + AlgebraicTypeUse::Uuid => write!(out, "{sdk_path}::Uuid")?, + AlgebraicTypeUse::ScheduleAt => write!(out, "{sdk_path}::ScheduleAt")?, AlgebraicTypeUse::Option(inner_ty) => { - write!(out, "Option::<")?; - write_type(module, out, inner_ty)?; + write_builtin(out, "::core::option", "Option")?; + write!(out, "::<")?; + write_type_in_context(module, out, inner_ty, context)?; write!(out, ">")?; } AlgebraicTypeUse::Result { ok_ty, err_ty } => { - write!(out, "Result::<")?; - write_type(module, out, ok_ty)?; + write_builtin(out, "::core::result", "Result")?; + write!(out, "::<")?; + write_type_in_context(module, out, ok_ty, context)?; write!(out, ", ")?; - write_type(module, out, err_ty)?; + write_type_in_context(module, out, err_ty, context)?; write!(out, ">")?; } AlgebraicTypeUse::Primitive(prim) => match prim { - PrimitiveType::Bool => write!(out, "bool")?, - PrimitiveType::I8 => write!(out, "i8")?, - PrimitiveType::U8 => write!(out, "u8")?, - PrimitiveType::I16 => write!(out, "i16")?, - PrimitiveType::U16 => write!(out, "u16")?, - PrimitiveType::I32 => write!(out, "i32")?, - PrimitiveType::U32 => write!(out, "u32")?, - PrimitiveType::I64 => write!(out, "i64")?, - PrimitiveType::U64 => write!(out, "u64")?, - PrimitiveType::I128 => write!(out, "i128")?, - PrimitiveType::U128 => write!(out, "u128")?, - PrimitiveType::I256 => write!(out, "__sats::i256")?, - PrimitiveType::U256 => write!(out, "__sats::u256")?, - PrimitiveType::F32 => write!(out, "f32")?, - PrimitiveType::F64 => write!(out, "f64")?, + PrimitiveType::Bool => write_builtin(out, "::core::primitive", "bool")?, + PrimitiveType::I8 => write_builtin(out, "::core::primitive", "i8")?, + PrimitiveType::U8 => write_builtin(out, "::core::primitive", "u8")?, + PrimitiveType::I16 => write_builtin(out, "::core::primitive", "i16")?, + PrimitiveType::U16 => write_builtin(out, "::core::primitive", "u16")?, + PrimitiveType::I32 => write_builtin(out, "::core::primitive", "i32")?, + PrimitiveType::U32 => write_builtin(out, "::core::primitive", "u32")?, + PrimitiveType::I64 => write_builtin(out, "::core::primitive", "i64")?, + PrimitiveType::U64 => write_builtin(out, "::core::primitive", "u64")?, + PrimitiveType::I128 => write_builtin(out, "::core::primitive", "i128")?, + PrimitiveType::U128 => write_builtin(out, "::core::primitive", "u128")?, + PrimitiveType::I256 => write!(out, "{sats_path}::i256")?, + PrimitiveType::U256 => write!(out, "{sats_path}::u256")?, + PrimitiveType::F32 => write_builtin(out, "::core::primitive", "f32")?, + PrimitiveType::F64 => write_builtin(out, "::core::primitive", "f64")?, }, - AlgebraicTypeUse::String => write!(out, "String")?, + AlgebraicTypeUse::String => write_builtin(out, "::std::string", "String")?, AlgebraicTypeUse::Array(elem_ty) => { - write!(out, "Vec::<")?; - write_type(module, out, elem_ty)?; + write_builtin(out, "::std::vec", "Vec")?; + write!(out, "::<")?; + write_type_in_context(module, out, elem_ty, context)?; write!(out, ">")?; } - AlgebraicTypeUse::Ref(r) => { - write!(out, "{}", type_ref_name(module, *r))?; + AlgebraicTypeUse::Ref(type_ref) => { + write!(out, "{type_prefix}{}", type_ref_name(module, *type_ref))?; } } Ok(()) @@ -1056,7 +1111,7 @@ struct FormattedArglist { impl FormattedArglist { fn for_arguments(module: &ModuleDef, params: &[(Identifier, AlgebraicTypeUse)]) -> Self { let mut arglist_no_delimiters = String::new(); - write_arglist_no_delimiters(module, &mut arglist_no_delimiters, params, None) + write_arglist_no_delimiters(module, &mut arglist_no_delimiters, params, None, TypeContext::Client) .expect("Writing to a String failed... huh?"); let mut arg_names = String::new(); @@ -1089,12 +1144,16 @@ fn print_spacetimedb_imports(output: &mut Indenter) { } fn print_file_header(output: &mut Indenter, include_version: bool) { + print_file_preamble(output, include_version); + print_spacetimedb_imports(output); +} + +fn print_file_preamble(output: &mut Indenter, include_version: bool) { print_auto_generated_file_comment(output); if include_version { print_auto_generated_version_comment(output); } writeln!(output, "{ALLOW_LINTS}"); - print_spacetimedb_imports(output); } // TODO: figure out if/when sum types should derive: @@ -1139,7 +1198,7 @@ pub fn define_enum_for_sum( "{", |out| { for (ident, ty) in variants { - write_enum_variant(module, out, ident, ty); + write_enum_variant(module, out, ident, ty, TypeContext::Client); out.newline(); } }, @@ -1149,7 +1208,13 @@ pub fn define_enum_for_sum( out.newline() } -fn write_enum_variant(module: &ModuleDef, out: &mut Indenter, ident: &Identifier, ty: &AlgebraicTypeUse) { +fn write_enum_variant( + module: &ModuleDef, + out: &mut Indenter, + ident: &Identifier, + ty: &AlgebraicTypeUse, + context: TypeContext, +) { let name = ident.deref().to_case(Case::Pascal); write!(out, "{name}"); @@ -1162,7 +1227,7 @@ fn write_enum_variant(module: &ModuleDef, out: &mut Indenter, ident: &Identifier // If the contained type is not a product, i.e. this variant has a single // member, write it tuple-style, with parens. write!(out, "("); - write_type(module, out, ty).unwrap(); + write_type_in_context(module, out, ty, context).unwrap(); write!(out, ")"); } writeln!(out, ","); @@ -1176,10 +1241,11 @@ fn write_struct_type_fields_in_braces( // Whether to print a `pub` qualifier on the fields. Necessary for `struct` defns, // disallowed for `enum` defns. pub_qualifier: bool, + context: TypeContext, ) { out.delimited_block( "{", - |out| write_arglist_no_delimiters(module, out, elements, pub_qualifier.then_some("pub")).unwrap(), + |out| write_arglist_no_delimiters(module, out, elements, pub_qualifier.then_some("pub"), context).unwrap(), "}", ); } @@ -1191,6 +1257,7 @@ fn write_arglist_no_delimiters( // Written before each line. Useful for `pub`. prefix: Option<&str>, + context: TypeContext, ) -> anyhow::Result<()> { for (ident, ty) in elements { if let Some(prefix) = prefix { @@ -1200,7 +1267,7 @@ fn write_arglist_no_delimiters( let name = ident.deref().to_case(Case::Snake); write!(out, "{name}: ")?; - write_type(module, out, ty)?; + write_type_in_context(module, out, ty, context)?; writeln!(out, ",")?; } @@ -1231,6 +1298,7 @@ fn define_struct_for_product( name: &str, elements: &[(Identifier, AlgebraicTypeUse)], vis: &str, + context: TypeContext, ) { print_struct_derives(out); @@ -1238,10 +1306,287 @@ fn define_struct_for_product( // TODO: if elements is empty, define a unit struct with no brace-delimited list of fields. write_struct_type_fields_in_braces( - module, out, elements, true, // `pub`-qualify fields. + module, out, elements, true, context, // `pub`-qualify fields. + ); + + out.newline(); +} + +fn generate_module_binding_type_file(module: &ModuleDef, typ: &TypeDef, dependency_name: Option<&str>) -> OutputFile { + let type_name = collect_case(Case::Pascal, typ.accessor_name.name_segments()); + let mut output = CodeIndenter::new(String::new(), INDENT); + let out = &mut output; + + print_file_preamble(out, false); + writeln!(out, "use super::Identity;"); + let def = &module.typespace_for_generate()[typ.ty]; + let plain_variants; + let (kind, members): (_, &[(Identifier, AlgebraicTypeUse)]) = match def { + AlgebraicTypeDef::Product(product) => ("struct", &product.elements), + AlgebraicTypeDef::Sum(sum) => ("enum", &sum.variants), + AlgebraicTypeDef::PlainEnum(plain_enum) => { + plain_variants = plain_enum + .variants + .iter() + .cloned() + .map(|variant| (variant, AlgebraicTypeUse::Unit)) + .collect::>(); + ("enum", &plain_variants) + } + }; + gen_and_print_imports(module, out, members, &[typ.ty]); + out.newline(); + writeln!(out, "#[derive(spacetimedb::SpacetimeType, Clone, PartialEq, Debug)]"); + if let Some(dependency_name) = dependency_name { + let scoped_name = format!("{dependency_name}.{}", typ.accessor_name); + writeln!(out, "#[sats(name = {})]", rust_string_literal(&scoped_name)); + } + if matches!(def, AlgebraicTypeDef::PlainEnum(_)) { + print_plain_enum_extra_derives(out); + } + write!(out, "pub {kind} {type_name} "); + out.delimited_block( + "{", + |out| { + if matches!(def, AlgebraicTypeDef::Product(_)) { + write_arglist_no_delimiters(module, out, members, Some("pub"), TypeContext::Receiver).unwrap(); + } else { + for (ident, ty) in members { + write_enum_variant(module, out, ident, ty, TypeContext::Receiver); + } + } + }, + "}\n", ); + OutputFile { + filename: type_module_name(&typ.accessor_name) + ".rs", + code: output.into_inner(), + } +} + +fn generate_module_bindings_root_file(module: &ModuleDef, type_refs: &BTreeSet) -> OutputFile { + let mut output = CodeIndenter::new(String::new(), INDENT); + let out = &mut output; + + print_file_preamble(out, false); + out.newline(); + writeln!(out, "use spacetimedb::spacetimedb_lib as __lib;"); + out.newline(); + print_module_decls( + iter_module_binding_types(module, type_refs).map(|ty| type_module_name(&ty.accessor_name)), + out, + ); out.newline(); + print_type_reexports(iter_module_binding_types(module, type_refs), out); + out.newline(); + print_module_binding_identity(out); + out.newline(); + for reducer in iter_reducers(module, CodegenVisibility::OnlyPublic) { + print_module_binding_reducer_handle(module, out, reducer); + out.newline(); + } + + OutputFile { + filename: "lib.rs".to_owned(), + code: output.into_inner(), + } +} + +fn print_type_reexports<'a>(types: impl IntoIterator, out: &mut Indenter) { + for ty in types { + let mod_name = type_module_name(&ty.accessor_name); + let type_name = collect_case(Case::Pascal, ty.accessor_name.name_segments()); + writeln!(out, "pub use {mod_name}::{type_name};"); + } +} + +fn iter_module_binding_types<'a>( + module: &'a ModuleDef, + type_refs: &'a BTreeSet, +) -> impl Iterator + 'a { + iter_types(module).filter(move |typ| type_refs.contains(&typ.ty)) +} + +fn module_binding_reducer_type_refs(module: &ModuleDef) -> BTreeSet { + let mut pending = Vec::new(); + for reducer in iter_reducers(module, CodegenVisibility::OnlyPublic) { + for (_, ty) in &reducer.params_for_generate.elements { + ty.for_each_ref(|reference| pending.push(reference)); + } + } + let mut type_refs = BTreeSet::new(); + while let Some(reference) = pending.pop() { + if !type_refs.insert(reference) { + continue; + } + match &module.typespace_for_generate()[reference] { + AlgebraicTypeDef::Product(product) => { + for (_, ty) in &product.elements { + ty.for_each_ref(|nested| pending.push(nested)); + } + } + AlgebraicTypeDef::Sum(sum) => { + for (_, ty) in &sum.variants { + ty.for_each_ref(|nested| pending.push(nested)); + } + } + AlgebraicTypeDef::PlainEnum(_) => {} + } + } + type_refs +} + +fn print_module_binding_identity(out: &mut Indenter) { + writeln!( + out, + " +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct Identity(pub spacetimedb::Identity); + +impl spacetimedb::SpacetimeType for Identity {{ + fn make_type( + typespace: &mut S, + ) -> spacetimedb::spacetimedb_lib::AlgebraicType {{ + ::make_type(typespace) + }} +}} + +impl spacetimedb::Serialize for Identity {{ + fn serialize( + &self, + serializer: S, + ) -> Result {{ + ::serialize(&self.0, serializer) + }} +}} + +impl<'de> spacetimedb::Deserialize<'de> for Identity {{ + fn deserialize>( + deserializer: D, + ) -> Result {{ + ::deserialize(deserializer).map(Self) + }} +}} +" + ); +} + +fn print_module_binding_reducer_handle(module: &ModuleDef, out: &mut Indenter, reducer: &ReducerDef) { + let handle_name = reducer_function_name(reducer); + let outbox_typecheck_macro = format!("__spacetimedb_typecheck_outbox_{handle_name}"); + let reducer_name = rust_string_literal(reducer.name.deref()); + let args_type = function_args_type_name(&reducer.accessor_name); + define_struct_for_product( + module, + out, + &args_type, + &reducer.params_for_generate.elements, + "pub", + TypeContext::Receiver, + ); + out.newline(); + let signature_hash = rust_string_literal(&reducer_signature_hash(module, reducer)); + let mut outbox_typecheck_calls = Vec::new(); + for (arg, ty) in &reducer.params_for_generate.elements { + let arg_name = arg.deref().to_case(Case::Snake); + let mut ty_name = String::new(); + write_type_in_context(module, &mut ty_name, ty, TypeContext::OutboxMacro).unwrap(); + writeln!( + out, + " +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_{handle_name}_{arg_name}_reject_duplicate {{ + ($module:ident;) => {{}}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as {arg_name}), $($rest:tt)*) => {{ + compile_error!(concat!( + \"outbox has multiple columns matching remote reducer parameter `\", + stringify!({arg_name}), + \"`\" + )); + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => {{ + $module::__spacetimedb_typecheck_outbox_{handle_name}_{arg_name}_reject_duplicate!($module; $($rest)*); + }}; +}} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_{handle_name}_{arg_name}_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_{handle_name}_{arg_name} {{ + ($module:ident;) => {{{{ + compile_error!(concat!( + \"outbox has no column matching remote reducer parameter `\", + stringify!({arg_name}), + \"`\" + )); + 0u16 + }}}}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as {arg_name}), $($rest:tt)*) => {{{{ + let _ = |x: $ty| {{ let _: {ty_name} = x; }}; + $module::__spacetimedb_typecheck_outbox_{handle_name}_{arg_name}_reject_duplicate!($module; $($rest)*); + $index + }}}}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => {{ + $module::__spacetimedb_typecheck_outbox_{handle_name}_{arg_name}!($module; $($rest)*) + }}; +}} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_{handle_name}_{arg_name}; +" + ); + outbox_typecheck_calls.push(format!( + "__outbox_receiver::__spacetimedb_typecheck_outbox_{handle_name}_{arg_name}!(__outbox_receiver; $($columns)*)" + )); + } + let outbox_typecheck_calls = outbox_typecheck_calls.join(",\n "); + writeln!( + out, + " +#[doc(hidden)] +macro_rules! {outbox_typecheck_macro} {{ + ($module:path, {{ $($columns:tt)* }}) => {{{{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[{outbox_typecheck_calls}] + }}}}; +}} + +#[doc(hidden)] +pub(crate) use {outbox_typecheck_macro}; +" + ); + writeln!( + out, + " +#[allow(non_camel_case_types)] +pub struct {handle_name}; + +impl spacetimedb::rt::RemoteReducer for {handle_name} {{ + type Identity = Identity; + const NAME: &'static str = {reducer_name}; + const SIGNATURE_HASH: &'static str = {signature_hash}; + type Args = {args_type}; +}} +" + ); +} + +fn reducer_signature_hash(module: &ModuleDef, reducer: &ReducerDef) -> String { + spacetimedb_schema::def::reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + module.typespace(), + ) + .to_hex() + .to_string() +} + +fn rust_string_literal(value: &str) -> String { + format!("{value:?}") } fn type_ref_module_name(module: &ModuleDef, type_ref: AlgebraicTypeRef) -> String { @@ -1305,19 +1650,15 @@ fn iter_module_names(module: &ModuleDef, visibility: CodegenVisibility) -> impl } /// Print `pub mod` declarations for all the files that will be generated for `items`. -fn print_module_decls(module: &ModuleDef, visibility: CodegenVisibility, out: &mut Indenter) { - for module_name in iter_module_names(module, visibility) { +fn print_module_decls(module_names: impl IntoIterator, out: &mut Indenter) { + for module_name in module_names { writeln!(out, "pub mod {module_name};"); } } /// Print appropriate reexports for all the files that will be generated for `items`. fn print_module_reexports(module: &ModuleDef, visibility: CodegenVisibility, out: &mut Indenter) { - for ty in iter_types(module) { - let mod_name = type_module_name(&ty.accessor_name); - let type_name = collect_case(Case::Pascal, ty.accessor_name.name_segments()); - writeln!(out, "pub use {mod_name}::{type_name};") - } + print_type_reexports(iter_types(module), out); for (_, accessor_name, _) in iter_table_names_and_types(module, visibility) { let mod_name = table_module_name(accessor_name); // TODO: More precise reexport: we want: @@ -1362,7 +1703,13 @@ fn print_reducer_enum_defn(module: &ModuleDef, visibility: CodegenVisibility, ou // like `Foo { bar: Baz, }`. // If it doesn't, generate a "unit variant" instead, // like `Foo,`. - write_struct_type_fields_in_braces(module, out, &reducer.params_for_generate.elements, false); + write_struct_type_fields_in_braces( + module, + out, + &reducer.params_for_generate.elements, + false, + TypeContext::Client, + ); } writeln!(out, ","); } @@ -2270,3 +2617,85 @@ fn gen_and_print_imports( print_imports(module, out, imports); } + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; + use spacetimedb_lib::AlgebraicType; + use std::sync::Arc; + + #[test] + fn type_rendering_preserves_context_in_nested_types() { + let mut builder = RawModuleDefV10Builder::new(); + let payload = builder.add_algebraic_type( + [], + "Payload", + AlgebraicType::product([("value", AlgebraicType::U64)]), + false, + ); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let option = |ty| AlgebraicTypeUse::Option(Arc::new(ty)); + let array = |ty| AlgebraicTypeUse::Array(Arc::new(ty)); + let result = |ok_ty, err_ty| AlgebraicTypeUse::Result { + ok_ty: Arc::new(ok_ty), + err_ty: Arc::new(err_ty), + }; + let types = [ + option(array(result( + AlgebraicTypeUse::Ref(payload), + AlgebraicTypeUse::Identity, + ))), + result( + AlgebraicTypeUse::Primitive(PrimitiveType::I256), + AlgebraicTypeUse::Primitive(PrimitiveType::U256), + ), + option(array(AlgebraicTypeUse::Timestamp)), + result(AlgebraicTypeUse::ConnectionId, AlgebraicTypeUse::TimeDuration), + result(AlgebraicTypeUse::Uuid, AlgebraicTypeUse::ScheduleAt), + ]; + let cases = [ + ( + TypeContext::Client, + [ + "Option::>>", + "Result::<__sats::i256, __sats::u256>", + "Option::>", + "Result::<__sdk::ConnectionId, __sdk::TimeDuration>", + "Result::<__sdk::Uuid, __sdk::ScheduleAt>", + ], + ), + ( + TypeContext::Receiver, + [ + "Option::>>", + "Result::", + "Option::>", + "Result::", + "Result::", + ], + ), + ( + TypeContext::OutboxMacro, + [ + "::core::option::Option::<::std::vec::Vec::<::core::result::Result::<$module::Payload, $module::Identity>>>", + "::core::result::Result::<::spacetimedb::sats::i256, ::spacetimedb::sats::u256>", + "::core::option::Option::<::std::vec::Vec::<::spacetimedb::Timestamp>>", + "::core::result::Result::<::spacetimedb::ConnectionId, ::spacetimedb::TimeDuration>", + "::core::result::Result::<::spacetimedb::Uuid, ::spacetimedb::ScheduleAt>", + ], + ), + ]; + for (context, expected_types) in cases { + for (ty, expected) in types.iter().zip(expected_types) { + let mut output = String::new(); + match context { + TypeContext::Client => write_type(&module, &mut output, ty), + _ => write_type_in_context(&module, &mut output, ty, context), + } + .unwrap(); + assert_eq!(output, expected); + } + } + } +} diff --git a/crates/codegen/tests/codegen.rs b/crates/codegen/tests/codegen.rs index d485a403d97..4acea1e0f11 100644 --- a/crates/codegen/tests/codegen.rs +++ b/crates/codegen/tests/codegen.rs @@ -1,4 +1,6 @@ -use spacetimedb_codegen::{generate, CodegenOptions, Csharp, ImportExtension, Rust, TypeScript}; +use spacetimedb_codegen::{ + generate, generate_receiver_bindings, CodegenOptions, Csharp, ImportExtension, ReceiverLanguage, Rust, TypeScript, +}; use spacetimedb_data_structures::map::HashMap; use spacetimedb_schema::def::ModuleDef; use spacetimedb_testing::modules::{CompilationMode, CompiledModule}; @@ -45,6 +47,154 @@ declare_tests! { test_codegen_rust => Rust, } +#[test] +fn test_codegen_rust_module_bindings() { + let module = compiled_module(); + let outfiles = generate_receiver_bindings(module, ReceiverLanguage::Rust, None) + .unwrap() + .into_iter() + .map(|f| (f.filename, f.code)) + .collect::>(); + + assert!(!outfiles.contains_key("repeating_test_arg_type.rs")); + assert!(!outfiles.contains_key("nonrepeating_test_arg_type.rs")); + assert!(outfiles.contains_key("test_a_type.rs")); + assert!(outfiles.contains_key("namespace_test_f_type.rs")); + assert!(!outfiles.contains_key("baz_type.rs")); + assert!(!outfiles.contains_key("foobar_type.rs")); + assert!(!outfiles.keys().any(|filename| filename.ends_with("_reducer.rs"))); + assert!(!outfiles.keys().any(|filename| filename.ends_with("_procedure.rs"))); + + let root = outfiles + .get("lib.rs") + .expect("rust module bindings should emit a root lib.rs"); + + assert!(root.contains("pub use test_a_type::TestA;")); + assert!(!root.contains("pub mod baz_type;")); + + assert!(root.contains("pub struct Identity(pub spacetimedb::Identity);")); + assert!(root.contains("pub struct add;")); + assert!(root.contains("impl spacetimedb::rt::RemoteReducer for add")); + assert!(root.contains("const NAME: &'static str = \"add\";")); + assert!(!root.contains("ARG_NAMES")); + assert!(root.contains("__spacetimedb_typecheck_outbox_add_name")); + let reducer = module + .reducers() + .find(|reducer| reducer.name.as_ref() == "add") + .unwrap(); + let expected_hash = spacetimedb_schema::def::reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + module.typespace(), + ); + assert!(root.contains(&format!( + "const SIGNATURE_HASH: &'static str = \"{}\";", + expected_hash.to_hex() + ))); + assert!(!root.contains("pub struct init;")); + assert!(!root.contains("pub struct client_connected;")); + assert!(!root.contains("pub struct repeating_test;")); + assert!(!root.contains("pub struct nonrepeating_test;")); + assert!(!root.contains("sleep_one_second")); + assert!(!root.contains("RemoteProcedures")); + assert!(!root.contains("DbConnection")); + + insta::assert_snapshot!(root); +} + +#[test] +fn rust_receiver_types_are_scoped_by_dependency_name() { + let module = compiled_module(); + let generate = |name| { + generate_receiver_bindings(module, ReceiverLanguage::Rust, Some(name)) + .unwrap() + .into_iter() + .map(|file| (file.filename, file.code)) + .collect::>() + }; + let game_world = generate("game_world"); + let auth = generate("auth"); + let type_file = "test_a_type.rs"; + assert!(game_world[type_file].contains("#[sats(name = \"game_world.TestA\")]")); + assert!(auth[type_file].contains("#[sats(name = \"auth.TestA\")]")); +} + +#[test] +fn test_codegen_rust_module_bindings_include_nested_reducer_types() { + use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; + use spacetimedb_lib::{AlgebraicType, ProductType}; + + let mut builder = RawModuleDefV10Builder::new(); + let state_ref = builder + .build_table_with_new_type("EmpireState", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + builder + .build_table_with_new_type("Unrelated", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + let op_ref = builder.add_algebraic_type( + [], + "EmpireStateOp", + AlgebraicType::sum([("Update", AlgebraicType::option(AlgebraicType::Ref(state_ref)))]), + false, + ); + builder.add_reducer( + "receive", + ProductType::from([("ops", AlgebraicType::array(AlgebraicType::Ref(op_ref)))]), + ); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let files = generate_receiver_bindings(&module, ReceiverLanguage::Rust, None) + .unwrap() + .into_iter() + .map(|file| (file.filename, file.code)) + .collect::>(); + + assert!(files.contains_key("empire_state_op_type.rs")); + assert!(files.contains_key("empire_state_type.rs")); + assert!(!files.contains_key("unrelated_type.rs")); + assert!(files["lib.rs"].contains("pub mod empire_state_op_type;")); + assert!(files["lib.rs"].contains("pub mod empire_state_type;")); + assert!(files["lib.rs"].contains("pub use empire_state_op_type::EmpireStateOp;")); + assert!(files["lib.rs"].contains("pub use empire_state_type::EmpireState;")); + assert!(!files["lib.rs"].contains("pub mod unrelated_type;")); +} + +#[test] +fn test_codegen_rust_outbox_fixture() { + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, ExplicitNames, RawModuleDefV10Builder}; + use spacetimedb_lib::{AlgebraicType, ProductType}; + + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(CaseConversionPolicy::SnakeCase); + builder.add_reducer("receivePing", ProductType::from([("Body", AlgebraicType::String)])); + // Noop uses case conversion; receivePing uses its explicit wire name. + builder.add_reducer("Noop", ProductType::unit()); + let mut names = ExplicitNames::default(); + names.insert_function("receivePing", "wire_ping"); + builder.add_explicit_names(names); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let files = generate_receiver_bindings(&module, ReceiverLanguage::Rust, None).unwrap(); + let root = &files.iter().find(|file| file.filename == "lib.rs").unwrap().code; + + // The CLI formats generated bindings, and CI checks the fixture with the repo's rustfmt config. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + fs_err::write(&path, root).unwrap(); + let status = std::process::Command::new("rustfmt") + .arg("--config-path") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.rustfmt.toml")) + .arg(&path) + .status() + .unwrap(); + assert!(status.success()); + + // The bindings UI tests compile this generated fixture and check its column mappings. + assert_eq!( + fs_err::read_to_string(path).unwrap().trim_end(), + include_str!("../../bindings/tests/fixtures/idc_receiver/lib.rs").trim_end() + ); +} + #[test] fn test_typescript_table_handles_are_camel_case() { let module = compiled_module(); diff --git a/crates/codegen/tests/outbox_builtin_shadowing.rs b/crates/codegen/tests/outbox_builtin_shadowing.rs new file mode 100644 index 00000000000..64c1df72490 --- /dev/null +++ b/crates/codegen/tests/outbox_builtin_shadowing.rs @@ -0,0 +1,109 @@ +use spacetimedb_codegen::{generate_receiver_bindings, ReceiverLanguage}; +use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; +use spacetimedb_lib::{AlgebraicType, ProductType}; +use spacetimedb_schema::def::ModuleDef; +use std::process::Command; + +#[test] +fn outbox_macros_resolve_builtins_independently_of_the_callers_scope() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer( + "receive", + ProductType::from([ + ("text", AlgebraicType::String), + ("values", AlgebraicType::array(AlgebraicType::U64)), + ("maybe", AlgebraicType::option(AlgebraicType::String)), + ( + "outcome", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + ), + ("number", AlgebraicType::U64), + ("timestamp", AlgebraicType::timestamp()), + ("identity", AlgebraicType::identity()), + ]), + ); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let project = tempfile::tempdir().unwrap(); + let receiver_dir = project.path().join("src/receiver"); + std::fs::create_dir_all(&receiver_dir).unwrap(); + for file in generate_receiver_bindings(&module, ReceiverLanguage::Rust, None).unwrap() { + let name = if file.filename == "lib.rs" { + "mod.rs" + } else { + &file.filename + }; + std::fs::write(receiver_dir.join(name), file.code).unwrap(); + } + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap(); + let bindings_path = serde_json::to_string(&repo.join("crates/bindings")).unwrap(); + std::fs::write( + project.path().join("Cargo.toml"), + format!( + r#"[package] +name = "outbox-builtin-shadowing" +version = "0.0.0" +edition = "2024" + +[workspace] + +[dependencies] +spacetimedb = {{ path = {bindings_path} }} +"# + ), + ) + .unwrap(); + + let shadows = r#" + struct String; + struct Vec(::core::marker::PhantomData); + struct Option(::core::marker::PhantomData); + struct Result(::core::marker::PhantomData<(T, E)>); + type u64 = (); + mod core {} + mod std {} + mod spacetimedb {} +"#; + // First compile the complete generated bindings in an ordinary sender, then + // expand the same receiver macro with builtin names shadowed at its call site. + for (scope, shadowing) in [("unshadowed", ""), ("shadowed", shadows)] { + let source = format!( + r#" +#![allow(dead_code, non_camel_case_types, unused_imports)] +mod receiver; +mod caller {{ + {shadowing} + fn check() {{ + let _ = crate::receiver::__spacetimedb_typecheck_outbox_receive!(crate::receiver, {{ + (0, text: ::std::string::String as text), + (1, values: ::std::vec::Vec<::core::primitive::u64> as values), + (2, maybe: ::core::option::Option<::std::string::String> as maybe), + (3, outcome: ::core::result::Result<(), ::std::string::String> as outcome), + (4, number: ::core::primitive::u64 as number), + (5, timestamp: ::spacetimedb::Timestamp as timestamp), + (6, identity: crate::receiver::Identity as identity), + }}); + }} +}} +"# + ); + std::fs::write(project.path().join("src/lib.rs"), source).unwrap(); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| repo.join("target")); + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .args(["check", "--offline", "--manifest-path"]) + .arg(project.path().join("Cargo.toml")) + .arg("--target-dir") + .arg(target) + .output() + .expect("cargo is required to check generated receiver bindings"); + assert!( + output.status.success(), + "generated receiver bindings failed in the {scope} sender:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap new file mode 100644 index 00000000000..b59166a561b --- /dev/null +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap @@ -0,0 +1,1003 @@ +--- +source: crates/codegen/tests/codegen.rs +expression: root +--- +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] + +use spacetimedb::spacetimedb_lib as __lib; + +pub mod test_a_type; +pub mod test_b_type; +pub mod namespace_test_c_type; +pub mod namespace_test_f_type; + +pub use test_a_type::TestA; +pub use test_b_type::TestB; +pub use namespace_test_c_type::NamespaceTestC; +pub use namespace_test_f_type::NamespaceTestF; + + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct Identity(pub spacetimedb::Identity); + +impl spacetimedb::SpacetimeType for Identity { + fn make_type( + typespace: &mut S, + ) -> spacetimedb::spacetimedb_lib::AlgebraicType { + ::make_type(typespace) + } +} + +impl spacetimedb::Serialize for Identity { + fn serialize( + &self, + serializer: S, + ) -> Result { + ::serialize(&self.0, serializer) + } +} + +impl<'de> spacetimedb::Deserialize<'de> for Identity { + fn deserialize>( + deserializer: D, + ) -> Result { + ::deserialize(deserializer).map(Self) + } +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AddArgs { + pub name: String, + pub age: u8, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_name_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(name), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_name_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_name_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_name { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(name), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::std::string::String = x; }; + $module::__spacetimedb_typecheck_outbox_add_name_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_name!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_name; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_age_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(age), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_age_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_age_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_age { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(age), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::core::primitive::u8 = x; }; + $module::__spacetimedb_typecheck_outbox_add_age_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_age!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_age; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_add_name!(__outbox_receiver; $($columns)*), + __outbox_receiver::__spacetimedb_typecheck_outbox_add_age!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add; + + +#[allow(non_camel_case_types)] +pub struct add; + +impl spacetimedb::rt::RemoteReducer for add { + type Identity = Identity; + const NAME: &'static str = "add"; + const SIGNATURE_HASH: &'static str = "99a35dc6bdb6cabe48f524346c1979dfe7e6259304f68fb48595ded5c0737c7f"; + type Args = AddArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AddPlayerArgs { + pub name: String, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_player_name_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(name), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_player_name_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_player_name_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_player_name { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(name), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::std::string::String = x; }; + $module::__spacetimedb_typecheck_outbox_add_player_name_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_player_name!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_player_name; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_player { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_add_player_name!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_player; + + +#[allow(non_camel_case_types)] +pub struct add_player; + +impl spacetimedb::rt::RemoteReducer for add_player { + type Identity = Identity; + const NAME: &'static str = "add_player"; + const SIGNATURE_HASH: &'static str = "085a98d187205ddd5cb574208fc8f7aa6728c078ae72865f742a48751056824d"; + type Args = AddPlayerArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AddPrivateArgs { + pub name: String, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_private_name_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(name), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_private_name_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_private_name_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_private_name { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(name), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::std::string::String = x; }; + $module::__spacetimedb_typecheck_outbox_add_private_name_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_add_private_name!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_private_name; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_add_private { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_add_private_name!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_add_private; + + +#[allow(non_camel_case_types)] +pub struct add_private; + +impl spacetimedb::rt::RemoteReducer for add_private { + type Identity = Identity; + const NAME: &'static str = "add_private"; + const SIGNATURE_HASH: &'static str = "085a98d187205ddd5cb574208fc8f7aa6728c078ae72865f742a48751056824d"; + type Args = AddPrivateArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssertCallerIdentityIsModuleIdentityArgs { + } + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_assert_caller_identity_is_module_identity { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_assert_caller_identity_is_module_identity; + + +#[allow(non_camel_case_types)] +pub struct assert_caller_identity_is_module_identity; + +impl spacetimedb::rt::RemoteReducer for assert_caller_identity_is_module_identity { + type Identity = Identity; + const NAME: &'static str = "assert_caller_identity_is_module_identity"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = AssertCallerIdentityIsModuleIdentityArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DeletePlayerArgs { + pub id: u64, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_player_id_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as id), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(id), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_delete_player_id_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_player_id_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_player_id { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(id), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as id), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::core::primitive::u64 = x; }; + $module::__spacetimedb_typecheck_outbox_delete_player_id_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_delete_player_id!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_player_id; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_player { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_delete_player_id!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_player; + + +#[allow(non_camel_case_types)] +pub struct delete_player; + +impl spacetimedb::rt::RemoteReducer for delete_player { + type Identity = Identity; + const NAME: &'static str = "delete_player"; + const SIGNATURE_HASH: &'static str = "f9843c43d6a007427a042049aa531419e42c5895ecfe92bf30bbd6f7389e9cb0"; + type Args = DeletePlayerArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DeletePlayersByNameArgs { + pub name: String, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_players_by_name_name_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(name), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_delete_players_by_name_name_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_players_by_name_name_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_players_by_name_name { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(name), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::std::string::String = x; }; + $module::__spacetimedb_typecheck_outbox_delete_players_by_name_name_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_delete_players_by_name_name!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_players_by_name_name; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_delete_players_by_name { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_delete_players_by_name_name!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_delete_players_by_name; + + +#[allow(non_camel_case_types)] +pub struct delete_players_by_name; + +impl spacetimedb::rt::RemoteReducer for delete_players_by_name { + type Identity = Identity; + const NAME: &'static str = "delete_players_by_name"; + const SIGNATURE_HASH: &'static str = "085a98d187205ddd5cb574208fc8f7aa6728c078ae72865f742a48751056824d"; + type Args = DeletePlayersByNameArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExpectEnvironmentArgs { + pub key: String, + pub expected: Option::, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_expect_environment_key_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as key), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(key), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_expect_environment_key_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_expect_environment_key_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_expect_environment_key { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(key), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as key), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::std::string::String = x; }; + $module::__spacetimedb_typecheck_outbox_expect_environment_key_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_expect_environment_key!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_expect_environment_key; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_expect_environment_expected_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as expected), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(expected), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_expect_environment_expected_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_expect_environment_expected_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_expect_environment_expected { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(expected), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as expected), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::core::option::Option::<::std::string::String> = x; }; + $module::__spacetimedb_typecheck_outbox_expect_environment_expected_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_expect_environment_expected!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_expect_environment_expected; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_expect_environment { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_expect_environment_key!(__outbox_receiver; $($columns)*), + __outbox_receiver::__spacetimedb_typecheck_outbox_expect_environment_expected!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_expect_environment; + + +#[allow(non_camel_case_types)] +pub struct expect_environment; + +impl spacetimedb::rt::RemoteReducer for expect_environment { + type Identity = Identity; + const NAME: &'static str = "expect_environment"; + const SIGNATURE_HASH: &'static str = "92fae346d390625a393c5a4470e5ae4c4111b7d7323984087899d521938ab49b"; + type Args = ExpectEnvironmentArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ListOverAgeArgs { + pub age: u8, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_list_over_age_age_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(age), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_list_over_age_age_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_list_over_age_age_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_list_over_age_age { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(age), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: ::core::primitive::u8 = x; }; + $module::__spacetimedb_typecheck_outbox_list_over_age_age_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_list_over_age_age!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_list_over_age_age; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_list_over_age { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_list_over_age_age!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_list_over_age; + + +#[allow(non_camel_case_types)] +pub struct list_over_age; + +impl spacetimedb::rt::RemoteReducer for list_over_age { + type Identity = Identity; + const NAME: &'static str = "list_over_age"; + const SIGNATURE_HASH: &'static str = "480a4dd20223acbee97594f8957ffae7757bd59f68b8e07a22b8900fb21e0cbd"; + type Args = ListOverAgeArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct LogModuleIdentityArgs { + } + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_log_module_identity { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_log_module_identity; + + +#[allow(non_camel_case_types)] +pub struct log_module_identity; + +impl spacetimedb::rt::RemoteReducer for log_module_identity { + type Identity = Identity; + const NAME: &'static str = "log_module_identity"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = LogModuleIdentityArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QueryPrivateArgs { + } + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_query_private { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_query_private; + + +#[allow(non_camel_case_types)] +pub struct query_private; + +impl spacetimedb::rt::RemoteReducer for query_private { + type Identity = Identity; + const NAME: &'static str = "query_private"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = QueryPrivateArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SayHelloArgs { + } + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_say_hello { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_say_hello; + + +#[allow(non_camel_case_types)] +pub struct say_hello; + +impl spacetimedb::rt::RemoteReducer for say_hello { + type Identity = Identity; + const NAME: &'static str = "say_hello"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = SayHelloArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TestArgs { + pub arg: TestA, + pub arg_2: TestB, + pub arg_3: NamespaceTestC, + pub arg_4: NamespaceTestF, +} + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(arg), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(arg), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: $module::TestA = x; }; + $module::__spacetimedb_typecheck_outbox_test_arg_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_2_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_2), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(arg_2), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_2_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_2_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_2 { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(arg_2), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_2), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: $module::TestB = x; }; + $module::__spacetimedb_typecheck_outbox_test_arg_2_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_2!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_2; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_3_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_3), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(arg_3), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_3_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_3_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_3 { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(arg_3), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_3), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: $module::NamespaceTestC = x; }; + $module::__spacetimedb_typecheck_outbox_test_arg_3_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_3!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_3; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_4_reject_duplicate { + ($module:ident;) => {}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_4), $($rest:tt)*) => { + compile_error!(concat!( + "outbox has multiple columns matching remote reducer parameter `", + stringify!(arg_4), + "`" + )); + }; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_4_reject_duplicate!($module; $($rest)*); + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_4_reject_duplicate; + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_arg_4 { + ($module:ident;) => {{ + compile_error!(concat!( + "outbox has no column matching remote reducer parameter `", + stringify!(arg_4), + "`" + )); + 0u16 + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as arg_4), $($rest:tt)*) => {{ + let _ = |x: $ty| { let _: $module::NamespaceTestF = x; }; + $module::__spacetimedb_typecheck_outbox_test_arg_4_reject_duplicate!($module; $($rest)*); + $index + }}; + ($module:ident; ($index:literal, $field:ident : $ty:ty as $other:ident), $($rest:tt)*) => { + $module::__spacetimedb_typecheck_outbox_test_arg_4!($module; $($rest)*) + }; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_arg_4; + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[__outbox_receiver::__spacetimedb_typecheck_outbox_test_arg!(__outbox_receiver; $($columns)*), + __outbox_receiver::__spacetimedb_typecheck_outbox_test_arg_2!(__outbox_receiver; $($columns)*), + __outbox_receiver::__spacetimedb_typecheck_outbox_test_arg_3!(__outbox_receiver; $($columns)*), + __outbox_receiver::__spacetimedb_typecheck_outbox_test_arg_4!(__outbox_receiver; $($columns)*)] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test; + + +#[allow(non_camel_case_types)] +pub struct test; + +impl spacetimedb::rt::RemoteReducer for test { + type Identity = Identity; + const NAME: &'static str = "test"; + const SIGNATURE_HASH: &'static str = "7c5f9ed84b96b24179d63afbe2e4e459cae4cb0b7076f237249d46ea295f698e"; + type Args = TestArgs; +} + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TestBtreeIndexArgsArgs { + } + + +#[doc(hidden)] +macro_rules! __spacetimedb_typecheck_outbox_test_btree_index_args { + ($module:path, { $($columns:tt)* }) => {{ + #[allow(unused_imports)] + use $module as __outbox_receiver; + &[] + }}; +} + +#[doc(hidden)] +pub(crate) use __spacetimedb_typecheck_outbox_test_btree_index_args; + + +#[allow(non_camel_case_types)] +pub struct test_btree_index_args; + +impl spacetimedb::rt::RemoteReducer for test_btree_index_args { + type Identity = Identity; + const NAME: &'static str = "test_btree_index_args"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = TestBtreeIndexArgsArgs; +} diff --git a/crates/core/src/host/idc_actor.rs b/crates/core/src/host/idc_actor.rs index 582286e8064..739bd53a926 100644 --- a/crates/core/src/host/idc_actor.rs +++ b/crates/core/src/host/idc_actor.rs @@ -512,7 +512,7 @@ fn load_pending_message( target_identity: stream.target_identity.0, ack_prefix: stream.ack_prefix, target_reducer: outbox.remote_reducer.clone(), - signature_hash: Hash::from_hex(&outbox.signature_hash)?, + signature_hash: outbox.signature_hash, reducer_args, outbox_row, on_result_reducer: outbox.on_result_reducer.clone(), @@ -968,9 +968,7 @@ mod tests { &AlgebraicType::unit(), &AlgebraicType::String, &spacetimedb_sats::Typespace::default(), - ) - .to_hex() - .to_string(), + ), }), ) } @@ -1366,7 +1364,10 @@ mod tests { ); assert_eq!(query["seq"], "1"); assert_eq!(query["ack_prefix"], "0"); - assert_eq!(query["signature_hash"], outbox_schema().outbox.unwrap().signature_hash); + assert_eq!( + query["signature_hash"], + outbox_schema().outbox.unwrap().signature_hash.to_hex().as_str() + ); assert_eq!(body.as_ref(), to_vec(&42u32).unwrap()); (StatusCode::from_u16(status).unwrap(), "receiver detail") } diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index 0f34275f174..340d25e70fe 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -454,7 +454,7 @@ pub(crate) mod tests { target_column: ColId(1), arg_columns: vec![], on_result_reducer: None, - signature_hash: "test-signature".into(), + signature_hash: spacetimedb_lib::Hash::ZERO, }); stdb.create_table(tx, schema) })?; diff --git a/crates/datastore/src/locking_tx_datastore/datastore.rs b/crates/datastore/src/locking_tx_datastore/datastore.rs index a88706a506b..0745d56bfab 100644 --- a/crates/datastore/src/locking_tx_datastore/datastore.rs +++ b/crates/datastore/src/locking_tx_datastore/datastore.rs @@ -3133,7 +3133,7 @@ pub(crate) mod tests { target_column: ColId(1), arg_columns: vec![ColId(2)], on_result_reducer: None, - signature_hash: "test-signature".into(), + signature_hash: spacetimedb_lib::Hash::ZERO, }); let datastore = get_datastore()?; @@ -3396,7 +3396,7 @@ pub(crate) mod tests { target_column: ColId(1), arg_columns: vec![], on_result_reducer: None, - signature_hash: "test-signature".into(), + signature_hash: spacetimedb_lib::Hash::ZERO, }; let row = to_vec(&product![1u64, IdentityViaU256(Identity::ONE)]).unwrap(); diff --git a/crates/datastore/src/locking_tx_datastore/state_view.rs b/crates/datastore/src/locking_tx_datastore/state_view.rs index 405d81dedee..b75f2c61e97 100644 --- a/crates/datastore/src/locking_tx_datastore/state_view.rs +++ b/crates/datastore/src/locking_tx_datastore/state_view.rs @@ -18,7 +18,7 @@ use core::ops::RangeBounds; use spacetimedb_lib::ConnectionId; use spacetimedb_primitives::{col_list, ColList, TableId}; use spacetimedb_sats::{product, AlgebraicValue}; -use spacetimedb_schema::schema::{ColumnSchema, IndexSchema, TableSchema, ViewDefInfo}; +use spacetimedb_schema::schema::{ColumnSchema, IndexSchema, OutboxSchema, TableSchema, ViewDefInfo}; use spacetimedb_table::table::IndexScanPointIter; use spacetimedb_table::{ blob_store::HashMapBlobStore, @@ -305,7 +305,12 @@ pub trait StateView { // `st_outbox` is missing until `migrate_system_tables` runs. // Treat that as "no outbox metadata" while reconstructing raw schemas. let outbox = match self.iter_by_col_eq(ST_OUTBOX_ID, StOutboxFields::TableId, value_eq) { - Ok(mut iter) => iter.next().map(StOutboxRow::try_from).transpose()?.map(Into::into), + Ok(mut iter) => iter + .next() + .map(StOutboxRow::try_from) + .transpose()? + .map(OutboxSchema::try_from) + .transpose()?, Err(DatastoreError::Table(TableError::IdNotFound(..))) => None, Err(e) => return Err(e), }; diff --git a/crates/datastore/src/locking_tx_datastore/tx_state.rs b/crates/datastore/src/locking_tx_datastore/tx_state.rs index 5b2f3c385e8..05dedbd21a4 100644 --- a/crates/datastore/src/locking_tx_datastore/tx_state.rs +++ b/crates/datastore/src/locking_tx_datastore/tx_state.rs @@ -188,7 +188,6 @@ impl MemoryUsage for PendingSchemaChange { .as_ref() .map(|name| name.as_ref().heap_usage()) .unwrap_or(0) - + outbox.signature_hash.heap_usage() }) .unwrap_or(0) } diff --git a/crates/datastore/src/system_tables/idc.rs b/crates/datastore/src/system_tables/idc.rs index 136d25f1630..a9d0adea770 100644 --- a/crates/datastore/src/system_tables/idc.rs +++ b/crates/datastore/src/system_tables/idc.rs @@ -260,20 +260,22 @@ impl From<(TableId, OutboxSchema)> for StOutboxRow { target_column: outbox.target_column, arg_columns: outbox.arg_columns, on_result_reducer: outbox.on_result_reducer.map(Into::into), - signature_hash: outbox.signature_hash, + signature_hash: outbox.signature_hash.to_hex().to_string(), } } } -impl From for OutboxSchema { - fn from(row: StOutboxRow) -> Self { - Self { +impl TryFrom for OutboxSchema { + type Error = DatastoreError; + + fn try_from(row: StOutboxRow) -> Result { + Ok(Self { remote_reducer: ReducerName::new(row.remote_reducer), target_column: row.target_column, arg_columns: row.arg_columns, on_result_reducer: row.on_result_reducer.map(ReducerName::new), - signature_hash: row.signature_hash, - } + signature_hash: Hash::from_hex(row.signature_hash).map_err(anyhow::Error::from)?, + }) } } @@ -379,3 +381,31 @@ pub(crate) fn st_inbound_msg_schema() -> TableSchema { pub(crate) fn st_outbox_schema() -> TableSchema { st_schema(ST_OUTBOX_NAME, ST_OUTBOX_ID) } + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_lib::bsatn; + + #[test] + fn outbox_signature_hash_uses_legacy_string_encoding() { + // Encode the existing persisted row format independently of StOutboxRow. + let legacy_bytes = bsatn::to_vec(&( + TableId(42), + "receive_ping", + ColId(1), + vec![ColId(2)], + None::<&str>, + "ab".repeat(32), + )) + .unwrap(); + let mut row: StOutboxRow = bsatn::from_slice(&legacy_bytes).unwrap(); + let outbox = OutboxSchema::try_from(row.clone()).unwrap(); + assert_eq!(outbox.signature_hash, Hash::from_byte_array([0xab; 32])); + let stored = StOutboxRow::from((row.table_id, outbox)); + assert_eq!(bsatn::to_vec(&stored).unwrap(), legacy_bytes); + + row.signature_hash = "invalid".into(); + assert!(OutboxSchema::try_from(row).is_err()); + } +} diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index 53e84dec7ff..85b9bc77f65 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -1052,7 +1052,7 @@ mod test { ColId(1), [ColId(2)], Option::<&str>::None, - "test-signature", + spacetimedb_lib::hash_bytes(b"test-signature"), ); } @@ -1101,15 +1101,27 @@ mod test { } else { table }; - table.finish(); + let row_type = table.finish(); + if let Some(on_result) = on_result { + builder.add_reducer( + on_result, + ProductType::from([ + ("row", row_type.into()), + ( + "result", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + ), + ]), + ); + } builder.add_outbox( "outbound_pings", "receive_ping", ColId(1), [ColId(2)], on_result, - "test-signature", + spacetimedb_lib::hash_bytes(b"test-signature"), ); builder diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index fd70fc77706..2110545e564 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -6,6 +6,7 @@ //! It allows easier future extensibility to add new kinds of definitions. use crate::db::raw_def::v9::{Lifecycle, RawIndexAlgorithm, TableAccess, TableType}; +use crate::Hash; use core::fmt; use spacetimedb_primitives::{ColId, ColList}; use spacetimedb_sats::raw_identifier::RawIdentifier; @@ -107,6 +108,9 @@ pub enum RawModuleDefV10Section { /// Outbox table definitions. Outboxes(Vec), + + /// Outboxes whose receiver signature is resolved from this module's reducers by the host. + LocalOutboxes(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -367,7 +371,27 @@ pub struct RawOutboxDefV10 { /// Optional local reducer called with the delivery result. pub on_result_reducer: Option, /// Hash of the receiver reducer signature as seen by sender bindings. - pub signature_hash: String, + pub signature_hash: Hash, +} + +/// An outbox referencing a reducer defined in the same module. +/// +/// The host resolves the source name and computes the signature after validating +/// the module. The target column can still address another database running that module. +#[derive(Debug, Clone, SpacetimeType)] +#[sats(crate = crate)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +pub struct RawLocalOutboxDefV10 { + /// The source name of the outbox table. + pub table_name: RawIdentifier, + /// The source name of a reducer in the same module, before explicit naming or casing. + pub reducer_source_name: RawIdentifier, + /// Column containing the receiver database identity. + pub target_column: ColId, + /// Columns to encode in the local reducer's parameter order. + pub arg_columns: Vec, + /// Optional local reducer called with the delivery result. + pub on_result_reducer: Option, } /// A reducer definition. @@ -725,6 +749,14 @@ impl RawModuleDefV10 { }) } + /// Get outboxes whose receiver signatures must be resolved by the host. + pub fn local_outboxes(&self) -> Option<&Vec> { + self.sections.iter().find_map(|s| match s { + RawModuleDefV10Section::LocalOutboxes(outboxes) => Some(outboxes), + _ => None, + }) + } + // Get the row-level security section, if present. pub fn row_level_security(&self) -> Option<&Vec> { self.sections.iter().find_map(|s| match s { @@ -1038,6 +1070,24 @@ impl RawModuleDefV10Builder { } } + fn local_outboxes_mut(&mut self) -> &mut Vec { + let idx = self + .module + .sections + .iter() + .position(|s| matches!(s, RawModuleDefV10Section::LocalOutboxes(_))) + .unwrap_or_else(|| { + self.module + .sections + .push(RawModuleDefV10Section::LocalOutboxes(Vec::new())); + self.module.sections.len() - 1 + }); + match &mut self.module.sections[idx] { + RawModuleDefV10Section::LocalOutboxes(outboxes) => outboxes, + _ => unreachable!("Just ensured LocalOutboxes section exists"), + } + } + /// Get mutable access to the environment section, creating it if missing. fn environment_mut(&mut self) -> &mut Vec { let idx = self @@ -1348,7 +1398,7 @@ impl RawModuleDefV10Builder { target_column: impl Into, arg_columns: impl IntoIterator, on_result_reducer: Option>, - signature_hash: impl Into, + signature_hash: Hash, ) { self.outboxes_mut().push(RawOutboxDefV10 { table_name: table_name.into(), @@ -1356,10 +1406,58 @@ impl RawModuleDefV10Builder { target_column: target_column.into(), arg_columns: arg_columns.into_iter().collect(), on_result_reducer: on_result_reducer.map(Into::into), - signature_hash: signature_hash.into(), + signature_hash, + }); + } + + /// Register a local receiver without computing its signature in the module. + pub fn add_local_outbox( + &mut self, + table_name: impl Into, + reducer_source_name: impl Into, + target_column: impl Into, + arg_columns: impl IntoIterator, + on_result_reducer: Option>, + ) { + self.local_outboxes_mut().push(RawLocalOutboxDefV10 { + table_name: table_name.into(), + reducer_source_name: reducer_source_name.into(), + target_column: target_column.into(), + arg_columns: arg_columns.into_iter().collect(), + on_result_reducer: on_result_reducer.map(Into::into), }); } + pub fn set_outbox_on_result( + &mut self, + table_name: impl Into, + on_result_reducer: impl Into, + ) { + let table_name = table_name.into(); + let on_result_reducer = Some(on_result_reducer.into()); + let callback = self + .module + .sections + .iter_mut() + .find_map(|section| match section { + RawModuleDefV10Section::Outboxes(outboxes) => outboxes + .iter_mut() + .find(|outbox| outbox.table_name == table_name) + .map(|outbox| &mut outbox.on_result_reducer), + RawModuleDefV10Section::LocalOutboxes(outboxes) => outboxes + .iter_mut() + .find(|outbox| outbox.table_name == table_name) + .map(|outbox| &mut outbox.on_result_reducer), + _ => None, + }) + .unwrap_or_else(|| panic!("on_result reducer references unknown outbox table `{table_name}`")); + assert!( + callback.is_none(), + "outbox table `{table_name}` has multiple on_result reducers" + ); + *callback = on_result_reducer; + } + pub fn add_submodule(&mut self, namespace: impl Into, module: RawModuleDefV10) { let submodule = RawSubmoduleV10 { namespace: namespace.into(), diff --git a/crates/sats/src/de.rs b/crates/sats/src/de.rs index 3a0987c9a74..e756fc36c15 100644 --- a/crates/sats/src/de.rs +++ b/crates/sats/src/de.rs @@ -6,7 +6,7 @@ mod impls; pub mod serde; #[doc(hidden)] -pub use impls::{visit_named_product, visit_seq_product, WithBound}; +pub use impls::{visit_named_product, visit_named_product_with_field_names, visit_seq_product, WithBound}; use crate::buffer::BufReader; use crate::{bsatn, i256, u256}; diff --git a/crates/sats/src/de/impls.rs b/crates/sats/src/de/impls.rs index ab2c1135b71..4e777a11be0 100644 --- a/crates/sats/src/de/impls.rs +++ b/crates/sats/src/de/impls.rs @@ -881,20 +881,34 @@ pub fn validate_seq_product<'de, A: SeqProductAccess<'de>>( /// Deserialize, provided the fields' types, a product value with named fields. pub fn visit_named_product<'de, A: super::NamedProductAccess<'de>>( + elems_tys: WithTypespace<[ProductTypeElement]>, + visitor: &impl ProductVisitor<'de>, + tup: A, +) -> Result { + let names = TupleNameVisitor { + elems: elems_tys.ty(), + kind: visitor.product_kind(), + }; + visit_named_product_with_field_names(elems_tys, visitor, tup, names) +} + +/// Deserialize a named product with a custom mapping from field names to element indices. +/// The field-name visitor must return indices within `elems_tys`. +pub fn visit_named_product_with_field_names<'de, A: super::NamedProductAccess<'de>>( elems_tys: WithTypespace<[ProductTypeElement]>, visitor: &impl ProductVisitor<'de>, mut tup: A, + field_names: impl FieldNameVisitor<'de, Output = usize> + Clone, ) -> Result { let elems = elems_tys.ty(); let mut elements = vec![None; elems.len()]; - let kind = visitor.product_kind(); // Deserialize a product value corresponding to each product type field. // This is worst case quadratic in complexity // as fields can be specified out of order (value side) compared to `elems` (type side). for _ in 0..elems.len() { // Deserialize a field name, match against the element types. - let index = tup.get_field_ident(TupleNameVisitor { elems, kind })?.ok_or_else(|| { + let index = tup.get_field_ident(field_names.clone())?.ok_or_else(|| { // Couldn't deserialize a field name. // Find the first field name we haven't filled an element for. let missing = elements.iter().position(|field| field.is_none()).unwrap(); @@ -965,6 +979,7 @@ pub fn validate_named_product<'de, A: super::NamedProductAccess<'de>>( } /// A visitor for extracting indices of field names in the elements of a [`ProductType`]. +#[derive(Clone, Copy)] struct TupleNameVisitor<'a> { /// The elements of a product type, in order. elems: &'a [ProductTypeElement], diff --git a/crates/schema/Cargo.toml b/crates/schema/Cargo.toml index f2b1e993da2..61c22a7c4de 100644 --- a/crates/schema/Cargo.toml +++ b/crates/schema/Cargo.toml @@ -39,6 +39,7 @@ spacetimedb-testing = { path = "../testing" } proptest.workspace = true serial_test.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 12acc9c8d73..e53864fab86 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -1406,7 +1406,7 @@ mod tests { primary_key: ColId, ) -> ModuleDef { create_module_def_v10(|builder| { - builder + let row_type = builder .build_table_with_new_type( "outbound_pings", ProductType::from([ @@ -1424,13 +1424,25 @@ mod tests { .finish(); if let Some((remote_reducer, arg_columns, on_result_reducer, signature_hash)) = outbox { + if let Some(on_result_reducer) = on_result_reducer { + builder.add_reducer( + on_result_reducer.to_owned(), + ProductType::from([ + ("row", row_type.into()), + ( + "result", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + ), + ]), + ); + } builder.add_outbox( "outbound_pings", remote_reducer.to_owned(), target_column, arg_columns, on_result_reducer.map(str::to_owned), - signature_hash.to_owned(), + hash_bytes(signature_hash.as_bytes()), ); } }) diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 8b59bb469de..5efe4c9e98d 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -15,7 +15,7 @@ //! After validation, a `ModuleDef` can be converted to the `*Schema` types in `crate::schema` for use in the database. //! (Eventually, we may unify these types...) -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Debug, Write}; use std::hash::Hash; @@ -46,12 +46,12 @@ use spacetimedb_lib::db::raw_def::v9::{ }; use spacetimedb_lib::db::view::{extract_view_return_product_type_ref, ViewKind}; use spacetimedb_lib::environment::EnvironmentSchema; -use spacetimedb_lib::{hash_bytes, ProductType, RawModuleDef}; +use spacetimedb_lib::{Hash as SpacetimeHash, ProductType, RawModuleDef}; use spacetimedb_primitives::{ ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, }; use spacetimedb_sats::raw_identifier::RawIdentifier; -use spacetimedb_sats::{bsatn, AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; pub mod deserialize; pub mod error; @@ -125,6 +125,10 @@ pub struct ModuleDef { /// The tables of the module definition. tables: IdentifierMap, + /// Source names of outboxes whose receiver belongs to this module. Retained so + /// mounting a submodule can qualify local receivers without changing remote ones. + local_outbox_tables: BTreeSet, + /// The reducers of the module definition. /// Note: this is using IndexMap because reducer order is important /// and must be preserved for future calls to `__call_reducer__`. @@ -312,6 +316,11 @@ impl ModuleDef { self.accessor_path = accessor_path.clone(); for table in self.tables.values_mut() { table.namespace = path.clone(); + if self.local_outbox_tables.contains(table.accessor_name.as_raw()) + && let Some(outbox) = &mut table.outbox + { + outbox.remote_reducer = ReducerName::new(path.join(outbox.remote_reducer.local().clone())); + } for index in table.indexes.values_mut() { index.namespace = path.clone(); } @@ -324,6 +333,13 @@ impl ModuleDef { if let Some(schedule) = &mut table.schedule { schedule.namespace = path.clone(); } + if let Some(callback) = table + .outbox + .as_mut() + .and_then(|outbox| outbox.on_result_reducer.as_mut()) + { + *callback = ReducerName::new(path.join(callback.local().clone())); + } } for view in self.views.values_mut() { view.namespace = path.clone(); @@ -1027,6 +1043,7 @@ impl From for RawModuleDefV9 { fn from(val: ModuleDef) -> Self { let ModuleDef { path: _, + local_outbox_tables: _, tables, views, reducers, @@ -1089,6 +1106,7 @@ impl From for RawModuleDefV10 { fn from(val: ModuleDef) -> Self { let ModuleDef { path: _, + local_outbox_tables: _, tables, views, reducers, @@ -1108,7 +1126,11 @@ impl From for RawModuleDefV10 { environment, } = val; - let mut sections = Vec::new(); + // Names are already canonicalized. Disable further case conversion so revalidation + // preserves them. + let mut sections = vec![RawModuleDefV10Section::CaseConversionPolicy( + raw_def::v10::CaseConversionPolicy::None, + )]; if let Some(environment) = environment { sections.push(RawModuleDefV10Section::Environment( environment.into_declarations().into_iter().map(|x| x.into()).collect(), @@ -1162,7 +1184,9 @@ impl From for RawModuleDefV10 { remote_reducer: outbox.remote_reducer.into(), target_column: outbox.target_column, arg_columns: outbox.arg_columns, - on_result_reducer: outbox.on_result_reducer.map(Into::into), + on_result_reducer: outbox + .on_result_reducer + .map(|name| reducers[name.local()].accessor_name.clone().into()), signature_hash: outbox.signature_hash, }); } @@ -1204,19 +1228,30 @@ impl From for RawModuleDefV10 { sections.push(RawModuleDefV10Section::Procedures(raw_procedures)); } + let raw_http_routes: Vec = http_routes + .into_iter() + .map(|route| RawHttpRouteDefV10 { + handler_function: http_handlers[&route.handler_name].accessor_name.clone().into(), + ..route.into() + }) + .collect(); let raw_http_handlers: Vec = http_handlers .into_values() - .map(|hd| RawHttpHandlerDefV10 { - source_name: hd.accessor_name.into(), + .map(|hd| { + explicit_names.insert_function( + RawIdentifier::from(hd.accessor_name.clone()), + RawIdentifier::from(hd.name.clone()), + ); + RawHttpHandlerDefV10 { + source_name: hd.accessor_name.into(), + } }) .collect(); if !raw_http_handlers.is_empty() { sections.push(RawModuleDefV10Section::HttpHandlers(raw_http_handlers)); } - if !http_routes.is_empty() { - let raw_http_routes: Vec = - http_routes.into_iter().map(RawHttpRouteDefV10::from).collect(); + if !raw_http_routes.is_empty() { sections.push(RawModuleDefV10Section::HttpRoutes(raw_http_routes)); } @@ -1406,7 +1441,7 @@ pub struct OutboxDef { /// Local reducer to invoke with the remote result before acknowledging this stream. pub on_result_reducer: Option, /// Hash of the receiver reducer signature as seen by sender bindings. - pub signature_hash: String, + pub signature_hash: SpacetimeHash, } impl TableDef { @@ -2342,6 +2377,7 @@ impl From for RawViewDefV10 { is_anonymous, is_public, params, + params_for_generate, return_type, fn_ptr, .. @@ -2351,7 +2387,7 @@ impl From for RawViewDefV10 { index: fn_ptr.into(), is_public, is_anonymous, - params, + params: canonical_params(params, ¶ms_for_generate), return_type, } } @@ -2443,85 +2479,23 @@ pub struct ReducerDef { impl ReducerDef { /// Parameter types with schema names. pub fn params(&self) -> ProductType { - let mut params = self.params.clone(); - for (param, (name, _)) in params.elements.iter_mut().zip(self.params_for_generate.elements.iter()) { - param.name = Some(name.clone().into()); - } - params + canonical_params(self.params.clone(), &self.params_for_generate) } } -const REDUCER_SIGNATURE_HASH_DOMAIN: &[u8] = b"spacetimedb:idc:reducer-signature:v1\0"; - -/// Computes the IDC signature hash from parameter names, order, types, and both return types. -/// -/// The encoding is the versioned domain prefix followed by BSATN -/// `(AlgebraicType::Product(params), ok_return_type, err_return_type, reducer_typespace)`, -/// hashed with Keccak-256. Reducer Typespace are a `Vec` in first-use order, -/// traversing parameters, then the success type, then the error type, kind of reducer local -/// typespace. -pub fn reducer_signature_hash( - params: &ProductType, - ok_return_type: &AlgebraicType, - err_return_type: &AlgebraicType, - typespace: &Typespace, -) -> spacetimedb_lib::Hash { - fn normalize( - ty: &mut AlgebraicType, - typespace: &Typespace, - refs: &mut BTreeMap, - reducer_typespace: &mut Typespace, - ) { - match ty { - AlgebraicType::Ref(original) => { - // Number reachable types in first-use order so unrelated types and - // their positions in the module's typespace do not affect the hash. - let local_idx = if let Some(local_idx) = refs.get(original) { - *local_idx - } else { - // Reserve the id before following references so recursive types - // can refer back to this definition while it is being copied. - let local_idx = reducer_typespace.add(AlgebraicType::unit()); - refs.insert(*original, local_idx); - let mut definition = typespace[*original].clone(); - normalize(&mut definition, typespace, refs, reducer_typespace); - reducer_typespace[local_idx] = definition; - local_idx - }; - *original = local_idx; - } - AlgebraicType::Product(product) => { - for field in &mut product.elements { - normalize(&mut field.algebraic_type, typespace, refs, reducer_typespace); - } - } - AlgebraicType::Sum(sum) => { - for variant in &mut sum.variants { - normalize(&mut variant.algebraic_type, typespace, refs, reducer_typespace); - } - } - AlgebraicType::Array(array) => normalize(&mut array.elem_ty, typespace, refs, reducer_typespace), - _ => {} - } +// Keep the original typespace references, but use the names resolved by validation so +// revalidating the raw definition with CaseConversionPolicy::None preserves the schema. +fn canonical_params(mut params: ProductType, params_for_generate: &ProductTypeDef) -> ProductType { + assert_eq!(params.elements.len(), params_for_generate.elements.len()); + for (param, (name, _)) in params.elements.iter_mut().zip(params_for_generate.elements.iter()) { + param.name = Some(name.clone().into()); } - - let mut params = AlgebraicType::Product(params.clone()); - let mut ok_return_type = ok_return_type.clone(); - let mut err_return_type = err_return_type.clone(); - let mut refs = BTreeMap::new(); - let mut reducer_typespace = Typespace::default(); - for ty in [&mut params, &mut ok_return_type, &mut err_return_type] { - normalize(ty, typespace, &mut refs, &mut reducer_typespace); - } - let mut bytes = REDUCER_SIGNATURE_HASH_DOMAIN.to_vec(); - bsatn::to_writer( - &mut bytes, - &(params, ok_return_type, err_return_type, reducer_typespace.types), - ) - .expect("validated signature should serialize"); - hash_bytes(&bytes) + params } +mod reducer_signature; +pub use reducer_signature::reducer_signature_hash; + impl From for RawReducerDefV9 { fn from(val: ReducerDef) -> Self { RawReducerDefV9 { @@ -2536,7 +2510,7 @@ impl From for RawReducerDefV10 { fn from(val: ReducerDef) -> Self { RawReducerDefV10 { source_name: val.accessor_name.into(), - params: val.params, + params: canonical_params(val.params, &val.params_for_generate), visibility: val.visibility.into(), ok_return_type: val.ok_return_type, err_return_type: val.err_return_type, @@ -2624,7 +2598,7 @@ impl From for RawProcedureDefV10 { fn from(val: ProcedureDef) -> Self { RawProcedureDefV10 { source_name: val.accessor_name.into(), - params: val.params, + params: canonical_params(val.params, &val.params_for_generate), return_type: val.return_type, visibility: val.visibility.into(), } @@ -2880,6 +2854,163 @@ mod tests { ); } + #[test] + fn v10_roundtrip_preserves_reducer_signature_casing() { + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}; + + for policy in [CaseConversionPolicy::None, CaseConversionPolicy::SnakeCase] { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(policy); + let item_ref = builder.add_algebraic_type( + [], + "Message", + AlgebraicType::product([("Item", AlgebraicType::U64)]), + false, + ); + builder.add_reducer( + "receive", + ProductType::from([("MessageBody", AlgebraicType::Ref(item_ref))]), + ); + builder.add_procedure( + "read_message", + ProductType::from([("MessageBody", AlgebraicType::Ref(item_ref))]), + AlgebraicType::Ref(item_ref), + ); + builder.add_view( + "messages", + 0, + true, + true, + ProductType::from([("MessageBody", AlgebraicType::Ref(item_ref))]), + AlgebraicType::array(AlgebraicType::Ref(item_ref)), + ); + let original: ModuleDef = builder.finish().try_into().expect("valid module"); + let roundtrip = + ModuleDef::try_from(RawModuleDefV10::from(original.clone())).expect("valid roundtripped module"); + let signature = |module: &ModuleDef| { + let (_, reducer) = module.reducer_by_name("receive").expect("receiver exists"); + reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + module.typespace(), + ) + }; + + assert_eq!(original.typespace(), roundtrip.typespace()); + assert_eq!(signature(&original), signature(&roundtrip)); + for (original, roundtrip) in original.procedures().zip(roundtrip.procedures()) { + assert_eq!(original.params_for_generate, roundtrip.params_for_generate); + } + for (original, roundtrip) in original.views().zip(roundtrip.views()) { + assert_eq!(original.params_for_generate, roundtrip.params_for_generate); + } + } + } + + #[test] + #[allow(non_snake_case)] + fn v10_canonical_params_deserialize_on_receiver() { + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}; + use spacetimedb_sats::{de::DeserializeSeed as _, product, WithTypespace}; + + #[derive(Debug, PartialEq, spacetimedb_sats::Deserialize)] + #[sats(crate = spacetimedb_sats)] + struct ReceiverMessage { + Item: u64, + } + + for policy in [CaseConversionPolicy::None, CaseConversionPolicy::SnakeCase] { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(policy); + let item_ref = builder.add_algebraic_type( + [], + "Message", + AlgebraicType::product([("Item", AlgebraicType::U64)]), + false, + ); + builder.add_reducer( + "receive", + ProductType::from([ + ("MessageBody", AlgebraicType::Ref(item_ref)), + ("RequestID", AlgebraicType::String), + ]), + ); + let original: ModuleDef = builder.finish().try_into().expect("valid module"); + let roundtrip = + ModuleDef::try_from(RawModuleDefV10::from(original.clone())).expect("valid roundtripped module"); + let (_, sender) = roundtrip.reducer_by_name("receive").unwrap(); + let (_, receiver) = original.reducer_by_name("receive").unwrap(); + let expected_names = if matches!(policy, CaseConversionPolicy::None) { + ["MessageBody", "RequestID"] + } else { + ["message_body", "request_id"] + }; + assert_eq!( + sender + .params + .elements + .iter() + .map(|param| param.name.as_ref().unwrap().as_ref()) + .collect::>(), + expected_names + ); + assert_eq!( + receiver.params.elements[0].name.as_ref().unwrap().as_ref(), + "MessageBody" + ); + + // Encode using the exported canonical schema, as a sender does. Distinct + // argument types make an accidental change in parameter order observable. + let args = product![product![42u64], "request-7"]; + let bytes = bsatn::to_vec(&WithTypespace::new(roundtrip.typespace(), &sender.params).with_value(&args)) + .expect("canonical sender arguments should encode"); + + // The host still decodes against the receiver's original parameter names. + let mut remaining = bytes.as_slice(); + let decoded = original + .arg_seed_for(receiver) + .deserialize(bsatn::Deserializer::new(&mut remaining)) + .expect("receiver should accept arguments encoded with canonical names"); + assert_eq!(decoded, args); + assert!(remaining.is_empty()); + + // A Rust receiver then decodes the same bytes into its source-language + // types. BSATN carries values in order, not schema field names. + let typed: (ReceiverMessage, String) = + bsatn::from_slice(&bytes).expect("Rust receiver should deserialize the original argument types"); + assert_eq!(typed, (ReceiverMessage { Item: 42 }, "request-7".to_owned())); + } + } + + #[test] + fn v10_roundtrip_preserves_http_handler_names() { + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}; + + for policy in [CaseConversionPolicy::SnakeCase, CaseConversionPolicy::None] { + for canonical_name in [None, Some("WireHandler")] { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(policy); + builder.add_http_handler("GetSimple"); + builder.add_http_route("GetSimple", MethodOrAny::Any, "/get"); + if let Some(canonical_name) = canonical_name { + let mut names = ExplicitNames::default(); + names.insert_function("GetSimple", canonical_name); + builder.add_http_handler("WireHandler"); + builder.add_http_route("WireHandler", MethodOrAny::Any, "/other"); + names.insert_function("WireHandler", "OtherHandler"); + builder.add_explicit_names(names); + } + let original: ModuleDef = builder.finish().try_into().expect("valid module"); + let roundtrip = ModuleDef::try_from(RawModuleDefV10::from(original.clone())) + .expect("valid roundtripped HTTP routes"); + + assert_eq!(original.http_handlers, roundtrip.http_handlers); + assert_eq!(original.http_routes, roundtrip.http_routes); + } + } + } + #[test] fn v10_roundtrip_preserves_column_defaults() { use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; diff --git a/crates/schema/src/def/deserialize.rs b/crates/schema/src/def/deserialize.rs index 6e2e0c80af8..d457492994b 100644 --- a/crates/schema/src/def/deserialize.rs +++ b/crates/schema/src/def/deserialize.rs @@ -3,6 +3,7 @@ use crate::{ def::{ProcedureDef, ReducerDef, ViewDef}, identifier::Identifier, + type_for_generate::ProductTypeDef, }; use spacetimedb_lib::{ sats::{self, de, impl_serialize, ser, ProductValue}, @@ -26,10 +27,14 @@ impl Copy for ArgsSeed<'_, Def> {} pub trait FunctionDef { fn params(&self) -> &ProductType; + fn params_for_generate(&self) -> &ProductTypeDef; fn name(&self) -> &Identifier; } impl FunctionDef for ReducerDef { + fn params_for_generate(&self) -> &ProductTypeDef { + &self.params_for_generate + } fn params(&self) -> &ProductType { &self.params } @@ -39,6 +44,9 @@ impl FunctionDef for ReducerDef { } impl FunctionDef for ProcedureDef { + fn params_for_generate(&self) -> &ProductTypeDef { + &self.params_for_generate + } fn params(&self) -> &ProductType { &self.params } @@ -48,6 +56,9 @@ impl FunctionDef for ProcedureDef { } impl FunctionDef for ViewDef { + fn params_for_generate(&self) -> &ProductTypeDef { + &self.params_for_generate + } fn params(&self) -> &ProductType { &self.params } @@ -94,7 +105,51 @@ impl<'de, Def: FunctionDef> de::ProductVisitor<'de> for ArgsSeed<'_, Def> { } fn visit_named_product>(self, tup: A) -> Result { - de::visit_named_product(self.0.map(|r| &*r.params().elements), &self, tup) + de::visit_named_product_with_field_names(self.0.map(|r| &*r.params().elements), &self, tup, self) + } +} + +impl<'de, Def: FunctionDef> de::FieldNameVisitor<'de> for ArgsSeed<'_, Def> { + type Output = usize; + + fn kind(&self) -> de::ProductKind { + de::ProductKind::ReducerArgs + } + + fn field_names(&self) -> impl '_ + Iterator> { + self.params().elements.iter().map(|param| param.name.as_deref()).chain( + self.0 + .ty() + .params_for_generate() + .elements + .iter() + .filter(|(name, _)| !self.params().elements.iter().any(|param| param.has_name(name))) + .map(|(name, _)| Some(name.as_ref())), + ) + } + + fn visit(self, name: &str) -> Result { + // Preserve existing requests when a canonical alias collides with another + // parameter's source name. Both spellings otherwise identify the same slot, + // so the shared product decoder also rejects duplicate aliases. + self.params() + .elements + .iter() + .position(|param| param.has_name(name)) + .or_else(|| { + self.0 + .ty() + .params_for_generate() + .elements + .iter() + .position(|(canonical, _)| canonical == name) + }) + .ok_or_else(|| E::unknown_field_name(name, &self)) + } + + fn visit_seq(self, index: usize) -> Self::Output { + assert!(index < self.params().elements.len()); + index } } @@ -111,3 +166,103 @@ impl_serialize!([] ReducerArgsWithSchema<'_>, (self, ser) => { } seq.end() }); + +#[cfg(test)] +mod tests { + use super::*; + use crate::def::ModuleDef; + use sats::{de::DeserializeSeed, product}; + use spacetimedb_lib::{ + db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}, + AlgebraicType, + }; + + fn decode(module: &ModuleDef, def: &impl FunctionDef, json: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(json); + let value = module + .arg_seed_for(def) + .deserialize(de::serde::SerdeDeserializer::new(&mut deserializer)) + .map_err(|error| error.0.to_string())?; + deserializer.end().map_err(|error| error.to_string())?; + Ok(value) + } + + fn check_parameter_aliases(module: &ModuleDef, def: &impl FunctionDef, policy: CaseConversionPolicy) { + let expected = product![42u64, "request-7"]; + assert_eq!(decode(module, def, r#"[42,"request-7"]"#).unwrap(), expected); + assert_eq!( + decode(module, def, r#"{"RequestID":"request-7","MessageBody":42}"#).unwrap(), + expected + ); + if matches!(policy, CaseConversionPolicy::SnakeCase) { + for json in [ + r#"{"message_body":42,"request_id":"request-7"}"#, + r#"{"RequestID":"request-7","message_body":42}"#, + ] { + assert_eq!(decode(module, def, json).unwrap(), expected); + } + for json in [ + r#"{"MessageBody":42,"message_body":43}"#, + r#"{"message_body":42,"MessageBody":43}"#, + ] { + assert!(decode(module, def, json).unwrap_err().contains("duplicate")); + } + } else { + assert!(decode(module, def, r#"{"message_body":42,"RequestID":"request-7"}"#).is_err()); + } + assert!(decode(module, def, r#"{"MessageBody":42}"#) + .unwrap_err() + .contains("missing")); + assert!(decode(module, def, r#"{"unknown":42}"#) + .unwrap_err() + .contains("unknown")); + } + + #[test] + fn named_json_parameter_aliases_are_shared_by_functions() { + for policy in [CaseConversionPolicy::None, CaseConversionPolicy::SnakeCase] { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(policy); + let params = ProductType::from([ + ("MessageBody", AlgebraicType::U64), + ("RequestID", AlgebraicType::String), + ]); + builder.add_reducer("receive", params.clone()); + builder.add_procedure("read_message", params.clone(), AlgebraicType::U64); + let row = builder.add_algebraic_type( + [], + "Message", + AlgebraicType::product([("value", AlgebraicType::U64)]), + false, + ); + builder.add_view( + "messages", + 0, + true, + true, + params, + AlgebraicType::array(AlgebraicType::Ref(row)), + ); + let module: ModuleDef = builder.finish().try_into().unwrap(); + check_parameter_aliases(&module, module.reducer_by_name("receive").unwrap().1, policy); + check_parameter_aliases(&module, module.procedures().next().unwrap(), policy); + check_parameter_aliases(&module, module.views().next().unwrap(), policy); + } + } + + #[test] + fn named_json_original_parameter_names_take_precedence_over_aliases() { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(CaseConversionPolicy::SnakeCase); + builder.add_reducer( + "receive", + ProductType::from([("fooBar", AlgebraicType::U64), ("foo_bar", AlgebraicType::String)]), + ); + let module: ModuleDef = builder.finish().try_into().unwrap(); + let reducer = module.reducer_by_name("receive").unwrap().1; + assert_eq!( + decode(&module, reducer, r#"{"foo_bar":"original","fooBar":42}"#).unwrap(), + product![42u64, "original"] + ); + } +} diff --git a/crates/schema/src/def/reducer_signature.rs b/crates/schema/src/def/reducer_signature.rs new file mode 100644 index 00000000000..0a383babd55 --- /dev/null +++ b/crates/schema/src/def/reducer_signature.rs @@ -0,0 +1,74 @@ +use spacetimedb_lib::{bsatn, hash_bytes, Hash}; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, ProductType, Typespace}; +use std::collections::BTreeMap; + +const REDUCER_SIGNATURE_HASH_DOMAIN: &[u8] = b"spacetimedb:idc:reducer-signature:v1\0"; + +/// Computes the IDC signature hash from parameter names, order, types, and both return types. +/// +/// The encoding is the versioned domain prefix followed by BSATN +/// `(AlgebraicType::Product(params), ok_return_type, err_return_type, reducer_typespace)`, +/// hashed with Keccak-256. Reducer Typespace are a `Vec` in first-use order, +/// traversing parameters, then the success type, then the error type, kind of reducer local +/// typespace. +pub fn reducer_signature_hash( + params: &ProductType, + ok_return_type: &AlgebraicType, + err_return_type: &AlgebraicType, + typespace: &Typespace, +) -> Hash { + fn normalize( + ty: &mut AlgebraicType, + typespace: &Typespace, + refs: &mut BTreeMap, + reducer_typespace: &mut Typespace, + ) { + match ty { + AlgebraicType::Ref(original) => { + // Number reachable types in first-use order so unrelated types and + // their positions in the module's typespace do not affect the hash. + let local_idx = if let Some(local_idx) = refs.get(original) { + *local_idx + } else { + // Reserve the id before following references so recursive types + // can refer back to this definition while it is being copied. + let local_idx = reducer_typespace.add(AlgebraicType::unit()); + refs.insert(*original, local_idx); + let mut definition = typespace[*original].clone(); + normalize(&mut definition, typespace, refs, reducer_typespace); + reducer_typespace[local_idx] = definition; + local_idx + }; + *original = local_idx; + } + AlgebraicType::Product(product) => { + for field in &mut product.elements { + normalize(&mut field.algebraic_type, typespace, refs, reducer_typespace); + } + } + AlgebraicType::Sum(sum) => { + for variant in &mut sum.variants { + normalize(&mut variant.algebraic_type, typespace, refs, reducer_typespace); + } + } + AlgebraicType::Array(array) => normalize(&mut array.elem_ty, typespace, refs, reducer_typespace), + _ => {} + } + } + + let mut params = AlgebraicType::Product(params.clone()); + let mut ok_return_type = ok_return_type.clone(); + let mut err_return_type = err_return_type.clone(); + let mut refs = BTreeMap::new(); + let mut reducer_typespace = Typespace::default(); + for ty in [&mut params, &mut ok_return_type, &mut err_return_type] { + normalize(ty, typespace, &mut refs, &mut reducer_typespace); + } + let mut bytes = REDUCER_SIGNATURE_HASH_DOMAIN.to_vec(); + bsatn::to_writer( + &mut bytes, + &(params, ok_return_type, err_return_type, reducer_typespace.types), + ) + .expect("validated signature should serialize"); + hash_bytes(&bytes) +} diff --git a/crates/schema/src/def/validate.rs b/crates/schema/src/def/validate.rs index 44829a0e8b8..a1ee4393a3d 100644 --- a/crates/schema/src/def/validate.rs +++ b/crates/schema/src/def/validate.rs @@ -2,6 +2,8 @@ use crate::error::ValidationErrors; +mod names; + pub mod v10; pub mod v8; pub mod v9; diff --git a/crates/schema/src/def/validate/names.rs b/crates/schema/src/def/validate/names.rs new file mode 100644 index 00000000000..fc8d51ef597 --- /dev/null +++ b/crates/schema/src/def/validate/names.rs @@ -0,0 +1,78 @@ +use convert_case::{Case, Casing}; +use spacetimedb_lib::db::raw_def::v10::CaseConversionPolicy; +use spacetimedb_sats::raw_identifier::RawIdentifier; +use spacetimedb_sats::{AlgebraicType, ProductType, Typespace}; + +/// Apply the module's case conversion policy to a source identifier. +pub fn canonical_name(name: &str, policy: CaseConversionPolicy) -> String { + match policy { + CaseConversionPolicy::None => name.to_owned(), + CaseConversionPolicy::SnakeCase => name.to_case(Case::Snake), + _ => unreachable!("unsupported case conversion policy"), + } +} + +/// Resolve a schema name, preserving an explicit override verbatim. +/// +/// Tables, functions, and namespaces use the same precedence rule. Applying case +/// conversion to an explicit name would change the registered name. +pub fn canonical_name_with_override( + source_name: &str, + explicit_name: Option<&RawIdentifier>, + policy: CaseConversionPolicy, +) -> RawIdentifier { + explicit_name + .cloned() + .unwrap_or_else(|| canonical_name(source_name, policy).into()) +} + +/// Resolve parameter names without changing their types or visiting inline field names. +/// +/// Keep missing names as `None` so validation can report its usual error with the +/// source location. Local outboxes hash the resulting validated reducer parameters. +pub fn canonical_parameter_names( + params: &ProductType, + policy: CaseConversionPolicy, +) -> impl Iterator> + '_ { + params.elements.iter().map(move |param| { + param + .name + .as_ref() + .map(|name| canonical_name(name.as_ref(), policy).into()) + }) +} + +/// Apply module naming rules to product fields and sum variants in a typespace. +pub fn canonicalize_typespace(policy: CaseConversionPolicy, typespace: &mut Typespace) { + for ty in &mut typespace.types { + canonicalize_type(policy, ty); + } +} + +fn canonicalize_type(policy: CaseConversionPolicy, ty: &mut AlgebraicType) { + if ty.is_special() { + return; + } + match ty { + AlgebraicType::Product(product) => { + for element in &mut product.elements { + if let Some(name) = &mut element.name { + *name = canonical_name(name.as_ref(), policy).into(); + } + canonicalize_type(policy, &mut element.algebraic_type); + } + } + AlgebraicType::Sum(sum) => { + for variant in &mut sum.variants { + if let Some(name) = &mut variant.name + && matches!(policy, CaseConversionPolicy::SnakeCase) + { + *name = name.to_string().to_case(Case::Camel).into(); + } + canonicalize_type(policy, &mut variant.algebraic_type); + } + } + AlgebraicType::Array(array) => canonicalize_type(policy, &mut array.elem_ty), + _ => {} + } +} diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 8e96422fedb..b6294bc91a6 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -10,7 +10,7 @@ use spacetimedb_primitives::ReducerId; use spacetimedb_sats::{AlgebraicType, Typespace, WithTypespace}; use crate::def::validate::v9::{ - check_function_names_are_unique, check_scheduled_functions_exist, convert, generate_schedule_name, + check_function_names_are_unique, check_scheduled_functions_exist, generate_schedule_name, generate_unique_constraint_name, identifier, CoreValidator, TableValidator, ViewValidator, }; use crate::def::*; @@ -64,31 +64,14 @@ impl ExplicitNamesLookup { } } -/// Internal representation of case conversion policy, used during validation to determine how to -/// apply case conversion to names. -#[derive(Clone, Copy, Debug)] -pub(crate) enum ValidationCase { - None, - SnakeCase, - CamelCase, -} - -impl From for ValidationCase { - fn from(policy: CaseConversionPolicy) -> Self { - match policy { - CaseConversionPolicy::None => ValidationCase::None, - CaseConversionPolicy::SnakeCase => ValidationCase::SnakeCase, - _ => panic!("Unsupported case conversion policy: {:?}", policy), - } - } -} +pub(crate) use spacetimedb_lib::db::raw_def::v10::CaseConversionPolicy as ValidationCase; /// Validate a `RawModuleDefV10` and convert it into a `ModuleDef`, /// or return a stream of errors if the definition is invalid. pub fn validate(def: RawModuleDefV10) -> Result { let environment = validate_environment(&def); let mut typespace = def.typespace().cloned().unwrap_or_else(|| Typespace::EMPTY.clone()); let known_type_definitions = def.types().into_iter().flatten().map(|def| def.ty); - let case_policy = def.case_conversion_policy().into(); + let case_policy = def.case_conversion_policy(); let explicit_names = def .explicit_names() .cloned() @@ -106,7 +89,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { // Original `typespace` needs to be preserved to be assign `accesor_name`s to columns. let typespace_with_accessor_names = typespace.clone(); // Apply case conversion to `typespace`. - CoreValidator::typespace_case_conversion(case_policy, &mut typespace); + super::names::canonicalize_typespace(case_policy, &mut typespace); let mut validator = ModuleValidatorV10 { core: CoreValidator { @@ -222,6 +205,8 @@ pub fn validate(def: RawModuleDefV10) -> Result { .unwrap_or_else(|| Ok(Vec::new())); let raw_outboxes = def.outboxes().cloned().unwrap_or_default(); + let local_outboxes = def.local_outboxes().cloned().unwrap_or_default(); + let local_outbox_tables = local_outboxes.iter().map(|outbox| outbox.table_name.clone()).collect(); // Validate lifecycle reducers - they reference reducers by name let lifecycle_validations = reducers @@ -279,10 +264,21 @@ pub fn validate(def: RawModuleDefV10) -> Result { schedules, lifecycle_validations, http_handlers_and_routes, + Ok(raw_outboxes), ) .combine_errors() .and_then( - |(mut tables, types, reducers, procedures, views, schedules, lifecycles, http_handlers_and_routes)| { + |( + mut tables, + types, + reducers, + procedures, + views, + schedules, + lifecycles, + http_handlers_and_routes, + mut raw_outboxes, + )| { let (mut reducers, mut procedures, mut views) = check_function_names_are_unique(reducers, procedures, views)?; // Attach lifecycles to their respective reducers @@ -291,7 +287,31 @@ pub fn validate(def: RawModuleDefV10) -> Result { // Attach schedules to their respective tables attach_schedules_to_tables(&mut tables, schedules)?; - attach_outboxes_to_tables(&mut tables, raw_outboxes)?; + // Local references use the same canonical reducer and typespace as + // delivery. Modules do not need to repeat validation or emit a hash. + for local in local_outboxes { + let (name, reducer) = reducers + .iter() + .find(|(_, reducer)| reducer.accessor_name.as_ref() == local.reducer_source_name.as_ref()) + .ok_or_else(|| ValidationError::MissingLocalOutboxReducer { + table_name: local.table_name.clone(), + reducer_name: local.reducer_source_name.clone(), + })?; + raw_outboxes.push(RawOutboxDefV10 { + table_name: local.table_name, + remote_reducer: name.clone().into(), + target_column: local.target_column, + arg_columns: local.arg_columns, + on_result_reducer: local.on_result_reducer, + signature_hash: reducer_signature_hash( + &reducer.params(), + &reducer.ok_return_type, + &reducer.err_return_type, + validator.core.typespace, + ), + }); + } + attach_outboxes_to_tables(&mut tables, &mut reducers, raw_outboxes, &mut validator.core)?; check_scheduled_functions_exist(&mut tables, &reducers, &procedures)?; change_scheduled_functions_and_lifetimes_visibility(&tables, &mut reducers, &mut procedures)?; @@ -328,6 +348,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { let typespace_for_generate = typespace_for_generate.finish(); let mut module_def = ModuleDef { + local_outbox_tables, // Set by `apply_namespace` below. path: NamespacePath::root(), accessor_path: NamespacePath::root(), @@ -380,10 +401,8 @@ fn resolve_namespace_ident( case_policy: ValidationCase, explicit_names: &ExplicitNamesLookup, ) -> Result { - let canonical = match explicit_names.namespaces.get(source) { - Some(canonical) => canonical.clone(), - None => convert(source.clone(), case_policy).into(), - }; + let canonical = + super::names::canonical_name_with_override(source.as_ref(), explicit_names.namespaces.get(source), case_policy); Identifier::new(canonical).map_err(|error| ValidationError::IdentifierError { error }.into()) } @@ -1192,15 +1211,27 @@ fn attach_schedules_to_tables( Ok(()) } -fn attach_outboxes_to_tables(tables: &mut HashMap, outboxes: Vec) -> Result<()> { +fn attach_outboxes_to_tables( + tables: &mut HashMap, + reducers: &mut IndexMap, + outboxes: Vec, + core: &mut CoreValidator<'_>, +) -> Result<()> { for outbox in outboxes { - let table_ident = identifier(outbox.table_name.clone())?; - let raw_table_name = outbox.table_name.clone(); + let RawOutboxDefV10 { + table_name: table_source_name, + remote_reducer, + target_column, + arg_columns, + on_result_reducer, + signature_hash, + } = outbox; + let table = tables .values_mut() - .find(|table| table.accessor_name == table_ident) - .ok_or_else(|| ValidationError::TableNotFound { - table: raw_table_name.clone(), + .find(|table| table.accessor_name.as_raw() == &table_source_name) + .ok_or_else(|| ValidationError::MissingOutboxTable { + table_name: table_source_name.clone(), })?; if table.outbox.is_some() { @@ -1236,7 +1267,7 @@ fn attach_outboxes_to_tables(tables: &mut HashMap, outboxe .into()); } - let Some(target_col) = table.columns.get(outbox.target_column.idx()) else { + let Some(target_col) = table.columns.get(target_column.idx()) else { return Err(ValidationError::OutboxTargetColumnMissing { table: table.name.clone(), } @@ -1249,7 +1280,7 @@ fn attach_outboxes_to_tables(tables: &mut HashMap, outboxe .into()); } - for arg_column in &outbox.arg_columns { + for arg_column in &arg_columns { if table.columns.get(arg_column.idx()).is_none() { return Err(ValidationError::OutboxArgumentColumnNotFound { table: table.name.clone(), @@ -1259,22 +1290,61 @@ fn attach_outboxes_to_tables(tables: &mut HashMap, outboxe } } + let on_result_reducer = on_result_reducer + .map(|name| { + if name.contains('.') || name.contains("::") { + return Err(ValidationError::OutboxOnResultMustBeLocal { + table: table.name.clone(), + reducer_name: name, + } + .into()); + } + let raw_name = name.clone(); + let name = core.resolve_function_ident(name)?; + if let Some(reducer) = reducers.get_mut(&name) { + let result_type = AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String); + let valid_params = match &*reducer.params.elements { + [row, result] => { + row.algebraic_type == AlgebraicType::Ref(table.product_type_ref) + && core + .typespace + .with_type(&result.algebraic_type) + .resolve_refs() + .is_ok_and(|ty| ty == result_type) + } + _ => false, + }; + if !valid_params { + return Err(ValidationError::IncorrectOutboxOnResultParams { + table: table.name.clone(), + reducer_name: raw_name, + expected: AlgebraicType::product([AlgebraicType::Ref(table.product_type_ref), result_type]) + .into(), + actual: reducer.params.clone().into(), + } + .into()); + } + reducer.visibility = crate::def::FunctionVisibility::Private; + Ok::<_, ValidationErrors>(name) + } else { + Err(ValidationError::MissingOutboxOnResult { + table: table.name.clone(), + reducer_name: raw_name, + } + .into()) + } + }) + .transpose()?; + table.outbox = Some(OutboxDef { remote_reducer: ReducerName::new( - NamespacedIdentifier::new(&RawNamespacedIdentifier::new(outbox.remote_reducer.into_inner())) + NamespacedIdentifier::new(&RawNamespacedIdentifier::new(remote_reducer.into_inner())) .map_err(|error| ValidationError::IdentifierError { error })?, ), - target_column: outbox.target_column, - arg_columns: outbox.arg_columns, - on_result_reducer: outbox - .on_result_reducer - .map(|name| { - NamespacedIdentifier::new(&RawNamespacedIdentifier::new(name.into_inner())) - .map(ReducerName::new) - .map_err(|error| ErrorStream::from(ValidationError::IdentifierError { error })) - }) - .transpose()?, - signature_hash: outbox.signature_hash, + target_column, + arg_columns, + on_result_reducer: on_result_reducer.map(ReducerName::new), + signature_hash, }); } @@ -1422,6 +1492,7 @@ mod tests { use crate::error::*; use crate::identifier::Identifier; use crate::identifier::NamespacePath; + use crate::reducer_name::ReducerName; use crate::type_for_generate::ClientCodegenError; use itertools::Itertools; @@ -1433,6 +1504,7 @@ mod tests { use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::db::view::QUERY_VIEW_RETURN_TAG; + use spacetimedb_lib::hash_bytes; use spacetimedb_lib::http::Method as HttpMethod; use spacetimedb_lib::ScheduleAt; use spacetimedb_primitives::{ColId, ColList, ColSet}; @@ -1753,13 +1825,14 @@ mod tests { .with_index_no_accessor_name(direct(0), "outbound_pings_msg_id_idx_btree") .finish(); builder.add_reducer("receive_ping", ProductType::from([("payload", AlgebraicType::String)])); + let signature_hash = hash_bytes(b"test-signature"); builder.add_outbox( "outbound_pings", "receive_ping", ColId(1), [ColId(2)], Option::<&str>::None, - "test-signature", + signature_hash, ); let module: ModuleDef = builder.finish().try_into().expect("valid outbox module"); @@ -1768,7 +1841,7 @@ mod tests { assert_eq!(&outbox.remote_reducer[..], "receive_ping"); assert_eq!(outbox.target_column, ColId(1)); assert_eq!(outbox.arg_columns, vec![ColId(2)]); - assert_eq!(outbox.signature_hash, "test-signature"); + assert_eq!(outbox.signature_hash, signature_hash); let schema = TableSchema::from_module_def(&module, table, (), TableId::SENTINEL); let outbox = schema.outbox.expect("TableSchema carries outbox metadata"); @@ -1800,7 +1873,7 @@ mod tests { ColId(1), [ColId(2)], Option::<&str>::None, - "test-signature", + spacetimedb_lib::hash_bytes(b"test-signature"), ); let result: Result = builder.finish().try_into(); @@ -2558,6 +2631,204 @@ mod tests { }); } + fn outbox_with_on_result() -> RawModuleDefV10Builder { + let mut builder = RawModuleDefV10Builder::new(); + let row_type = builder + .build_table_with_new_type( + "PingOutbox", + ProductType::from([ + ("msg_id", AlgebraicType::U64), + ("target", AlgebraicType::U256), + ("payload", AlgebraicType::String), + ]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index_no_accessor_name(direct(0), "ping_outbox_msg_id_idx_btree") + .finish(); + builder.add_reducer( + "on_ping_result", + ProductType::from([ + ("row", row_type.into()), + ( + "result", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + ), + ]), + ); + builder.add_outbox( + "PingOutbox", + "receive_ping", + ColId(1), + [ColId(2)], + Some("on_ping_result"), + hash_bytes(b"receive_ping"), + ); + builder + } + + #[test] + fn outbox_on_result_reducer_is_attached() { + let def: ModuleDef = outbox_with_on_result().finish().try_into().unwrap(); + let table = &def.tables[&expect_identifier("ping_outbox")]; + let outbox = table.outbox.as_ref().unwrap(); + + assert_eq!(outbox.remote_reducer, ReducerName::for_test("receive_ping")); + assert_eq!(outbox.target_column, ColId(1)); + assert_eq!(outbox.on_result_reducer, Some(ReducerName::for_test("on_ping_result"))); + assert_eq!( + def.reducers[&expect_identifier("on_ping_result")].visibility, + FunctionVisibility::Private + ); + } + + #[test] + fn outbox_on_result_accepts_referenced_result_and_arbitrary_parameter_names() { + let mut builder = outbox_with_on_result(); + let result_type = builder.add_algebraic_type( + [], + "DeliveryResult", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + true, + ); + let mut raw = builder.finish(); + for section in &mut raw.sections { + if let RawModuleDefV10Section::Reducers(reducers) = section { + reducers[0].params.elements[0].name = Some("message".into()); + reducers[0].params.elements[1].name = Some("outcome".into()); + reducers[0].params.elements[1].algebraic_type = result_type.into(); + } + } + let def: ModuleDef = raw.try_into().unwrap(); + assert_eq!( + def.reducers[&expect_identifier("on_ping_result")].visibility, + FunctionVisibility::Private + ); + } + + #[test] + fn outbox_on_result_rejects_invalid_signatures() { + let raw = outbox_with_on_result().finish(); + let row = AlgebraicType::Ref(raw.tables().unwrap()[0].product_type_ref); + let result = AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String); + for params in [ + ProductType::unit(), + ProductType::from([("row", row.clone())]), + ProductType::from([("row", AlgebraicType::U64), ("result", result.clone())]), + ProductType::from([("result", result.clone()), ("row", row.clone())]), + ProductType::from([ + ("row", row.clone()), + ( + "result", + AlgebraicType::result(AlgebraicType::U64, AlgebraicType::String), + ), + ]), + ProductType::from([ + ("row", row.clone()), + ( + "result", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::U64), + ), + ]), + ProductType::from([ + ("row", row.clone()), + ("result", AlgebraicType::option(AlgebraicType::String)), + ]), + ProductType::from([("row", row), ("result", result), ("extra", AlgebraicType::U64)]), + ] { + let mut invalid = raw.clone(); + for section in &mut invalid.sections { + if let RawModuleDefV10Section::Reducers(reducers) = section { + reducers[0].params = params.clone(); + } + } + let result: Result = invalid.try_into(); + expect_error_matching!(result, ValidationError::IncorrectOutboxOnResultParams { + table, + reducer_name, + .. + } => { + assert_eq!(table, &expect_identifier("ping_outbox")); + assert_eq!(&**reducer_name, "on_ping_result"); + true + }); + } + } + + #[test] + fn outbox_on_result_rejects_cross_module_callbacks() { + for policy in [CaseConversionPolicy::SnakeCase, CaseConversionPolicy::None] { + let table_name = if matches!(policy, CaseConversionPolicy::None) { + "PingOutbox" + } else { + "ping_outbox" + }; + for callback in ["child.on_ping_result", "child::on_ping_result"] { + let mut child = RawModuleDefV10Builder::new(); + child.add_reducer("on_ping_result", ProductType::unit()); + let mut parent = outbox_with_on_result(); + parent.set_case_conversion_policy(policy); + parent.add_reducer("child_on_ping_result", ProductType::unit()); + let mut names = ExplicitNames::default(); + names.insert_function(callback, "child_on_ping_result"); + parent.add_explicit_names(names); + parent.add_submodule("child", child.finish()); + let mut raw = parent.finish(); + for section in &mut raw.sections { + if let RawModuleDefV10Section::Outboxes(outboxes) = section { + outboxes[0].on_result_reducer = Some(callback.into()); + } + } + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::OutboxOnResultMustBeLocal { + table, + reducer_name, + } => { + assert_eq!(table, &expect_identifier(table_name)); + assert_eq!(&**reducer_name, callback); + true + }); + } + } + } + + #[test] + fn outbox_on_result_uses_canonical_submodule_namespace_and_round_trips() { + let mut child = outbox_with_on_result(); + let mut names = ExplicitNames::default(); + names.insert_table("PingOutbox", "WireOutbox"); + names.insert_function("on_ping_result", "ResultDone"); + child.add_explicit_names(names); + + let mut middle = RawModuleDefV10Builder::new(); + middle.add_submodule("innerMount", child.finish()); + let mut names = ExplicitNames::default(); + names.insert_namespace("innerMount", "InnerWire"); + middle.add_explicit_names(names); + + let mut root = RawModuleDefV10Builder::new(); + root.add_submodule("outerLib", middle.finish()); + let mut def: ModuleDef = root.finish().try_into().unwrap(); + + for _ in 0..3 { + let child = &def.submodules()["outer_lib"].submodules()["InnerWire"]; + let table = &child.tables[&expect_identifier("WireOutbox")]; + let outbox = table.outbox.as_ref().unwrap(); + let callback = outbox.on_result_reducer.as_ref().unwrap(); + assert_eq!(&**callback, "outer_lib.InnerWire.ResultDone"); + assert_eq!( + def.reducer_by_name(callback).unwrap().1.visibility, + FunctionVisibility::Private + ); + assert_eq!(outbox.remote_reducer, ReducerName::for_test("receive_ping")); + assert_eq!(&*table.accessor_name, "PingOutbox"); + + let raw: RawModuleDefV10 = def.into(); + def = raw.try_into().expect("mounted outbox callback should round-trip"); + } + } + #[test] fn missing_scheduled_reducer() { let mut builder = RawModuleDefV10Builder::new(); diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index 5fd18abc64c..b661f81ae93 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -1,3 +1,4 @@ +use super::names::{canonical_name_with_override, canonical_parameter_names}; use crate::def::validate::v10::{ExplicitNamesLookup, ValidationCase}; use crate::def::*; use crate::error::{RawColumnName, ValidationError}; @@ -154,6 +155,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { let typespace_for_generate = typespace_for_generate.finish(); let mut module_def = ModuleDef { + local_outbox_tables: Default::default(), // V9 has no submodules, so every def is at the root. path: NamespacePath::root(), accessor_path: NamespacePath::root(), @@ -631,11 +633,8 @@ impl CoreValidator<'_> { source: RawIdentifier, lookup: &HashMap, ) -> Result { - if let Some(canonical_name) = lookup.get(&source) { - Identifier::new(canonical_name.clone()).map_err(|error| ValidationError::IdentifierError { error }.into()) - } else { - self.resolve_identifier_with_case(source) - } + let canonical = canonical_name_with_override(source.as_ref(), lookup.get(&source), self.case_policy); + identifier(canonical) } pub(crate) fn resolve_table_ident(&self, source: RawIdentifier) -> Result { @@ -670,68 +669,6 @@ impl CoreValidator<'_> { Identifier::new(ident.into()).map_err(|error| ValidationError::IdentifierError { error }.into()) } - // Recursive function to change typenames in the typespace according to the case conversion - // policy. - pub(crate) fn typespace_case_conversion(case_policy: ValidationCase, typespace: &mut Typespace) { - let case_policy_for_enum_variants = if matches!(case_policy, ValidationCase::SnakeCase) { - ValidationCase::CamelCase - } else { - case_policy - }; - - for ty in &mut typespace.types { - Self::convert_algebraic_type(ty, case_policy, case_policy_for_enum_variants); - } - } - - // Recursively convert names in an AlgebraicType - fn convert_algebraic_type( - ty: &mut AlgebraicType, - case_policy: ValidationCase, - case_policy_for_enum_variants: ValidationCase, - ) { - if ty.is_special() { - return; - } - match ty { - AlgebraicType::Product(product) => { - for element in &mut product.elements.iter_mut() { - // Convert the element name if it exists - if let Some(name) = element.name() { - let new_name = convert(name.clone(), case_policy); - element.name = Some(new_name.into()); - } - // Recursively convert the element's type - Self::convert_algebraic_type( - &mut element.algebraic_type, - case_policy, - case_policy_for_enum_variants, - ); - } - } - AlgebraicType::Sum(sum) => { - for variant in &mut sum.variants.iter_mut() { - // Convert the variant name if it exists - if let Some(name) = variant.name() { - let new_name = convert(name.clone(), case_policy_for_enum_variants); - variant.name = Some(new_name.into()) - } - // Recursively convert the variant's type - Self::convert_algebraic_type( - &mut variant.algebraic_type, - case_policy, - case_policy_for_enum_variants, - ); - } - } - AlgebraicType::Array(array) => { - // Arrays contain a base type that might need conversion - Self::convert_algebraic_type(&mut array.elem_ty, case_policy, case_policy_for_enum_variants); - } - _ => {} - } - } - pub(crate) fn params_for_generate( &mut self, params: &ProductType, @@ -740,12 +677,13 @@ impl CoreValidator<'_> { params .elements .iter() + // Retain raw parameters for error locations and typespace references while + // resolving the names used by codegen and reducer signature hashing. + .zip(canonical_parameter_names(params, self.case_policy)) .enumerate() - .map(|(position, param)| { + .map(|(position, (param, canonical_name))| { let location = || make_type_location(position, param.name().cloned()); - let param_name = param - .name() - .cloned() + let param_name = canonical_name .ok_or_else(|| { ValidationError::ClientCodegenError { location: location(), @@ -753,7 +691,7 @@ impl CoreValidator<'_> { } .into() }) - .and_then(|s| self.resolve_identifier_with_case(s)); + .and_then(identifier); let ty_use = self.validate_for_type_use(location, ¶m.algebraic_type); (param_name, ty_use).combine_errors() }) @@ -1513,13 +1451,7 @@ pub fn generate_unique_constraint_name( // Identifier::new(name).map_err(|error| ValidationError::IdentifierError { error }.into()) //} pub(crate) fn convert(identifier: RawIdentifier, policy: ValidationCase) -> String { - let identifier = identifier.to_string(); - - match policy { - ValidationCase::SnakeCase => identifier.to_case(Case::Snake), - ValidationCase::CamelCase => identifier.to_case(Case::Camel), - ValidationCase::None => identifier, - } + super::names::canonical_name(identifier.as_ref(), policy) } /// Check that every [`ScheduleDef`]'s `function_name` refers to a real reducer or procedure diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 391db8584cd..544c1838e92 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -152,6 +152,32 @@ pub enum ValidationError { TableNotFound { table: RawIdentifier }, #[error("table {table} is assigned in multiple outboxes")] DuplicateOutbox { table: Identifier }, + #[error("outbox table {} not found", table_name)] + MissingOutboxTable { table_name: RawIdentifier }, + #[error("local receiver reducer {reducer_name} for outbox {table_name} does not exist")] + MissingLocalOutboxReducer { + table_name: RawIdentifier, + reducer_name: RawIdentifier, + }, + #[error("on_result reducer {reducer_name} for outbox {table} does not exist")] + MissingOutboxOnResult { + table: Identifier, + reducer_name: RawIdentifier, + }, + #[error("on_result reducer {reducer_name} for outbox {table} expected parameters {expected}, but has {actual}")] + IncorrectOutboxOnResultParams { + table: Identifier, + reducer_name: RawIdentifier, + expected: PrettyAlgebraicType, + actual: PrettyAlgebraicType, + }, + #[error( + "on_result reducer {reducer_name} for outbox {table} must be an unqualified name in the same module; cross-module callbacks are not supported" + )] + OutboxOnResultMustBeLocal { + table: Identifier, + reducer_name: RawIdentifier, + }, #[error("outbox table {table} must not be an event table")] OutboxEventTable { table: Identifier }, #[error("outbox table {table} must not be a scheduled table")] diff --git a/crates/schema/src/schema.rs b/crates/schema/src/schema.rs index 67ec9bb35ff..78df10a419a 100644 --- a/crates/schema/src/schema.rs +++ b/crates/schema/src/schema.rs @@ -14,6 +14,7 @@ use itertools::Itertools; use spacetimedb_lib::db::auth::{StAccess, StTableType}; use spacetimedb_lib::db::raw_def::v9::RawSql; use spacetimedb_lib::db::raw_def::{generate_cols_name, RawConstraintDefV8}; +use spacetimedb_lib::Hash as SpacetimeHash; use spacetimedb_primitives::*; use spacetimedb_sats::product_value::InvalidFieldError; use spacetimedb_sats::raw_identifier::{RawIdentifier, RawNamespacedIdentifier}; @@ -1023,7 +1024,7 @@ impl Schema for TableSchema { target_column: outbox.target_column, arg_columns: outbox.arg_columns.clone(), on_result_reducer: outbox.on_result_reducer.clone(), - signature_hash: outbox.signature_hash.clone(), + signature_hash: outbox.signature_hash, }); TableSchema::new( @@ -1471,7 +1472,7 @@ pub struct OutboxSchema { /// Local reducer to invoke with the remote result before acknowledging this stream. pub on_result_reducer: Option, /// Hash of the receiver reducer signature as seen by the sender bindings. - pub signature_hash: String, + pub signature_hash: SpacetimeHash, } impl OutboxSchema { diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 5058dfc5c24..730c42be995 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -856,6 +856,13 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-idc-receiver" +version = "0.1.0" +dependencies = [ + "spacetimedb", +] + [[package]] name = "smoketest-module-logs-level-filter" version = "0.1.0" diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index 447c7cef977..0c57f19abb2 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -117,6 +117,7 @@ members = [ "logs-level-filter", # Misc tests + "idc-receiver", "namespaces", "new-user-flow", "module-nested-op", diff --git a/crates/smoketests/modules/idc-receiver/Cargo.toml b/crates/smoketests/modules/idc-receiver/Cargo.toml new file mode 100644 index 00000000000..a359cb3ad5f --- /dev/null +++ b/crates/smoketests/modules/idc-receiver/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-idc-receiver" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/idc-receiver/src/lib.rs b/crates/smoketests/modules/idc-receiver/src/lib.rs new file mode 100644 index 00000000000..ccbfc56d66c --- /dev/null +++ b/crates/smoketests/modules/idc-receiver/src/lib.rs @@ -0,0 +1,58 @@ +use spacetimedb::Table; + +const MODULE_VERSION: u32 = 1; + +#[derive(spacetimedb::SpacetimeType)] +pub struct PingDetails { + pub labels: Vec, + pub checkpoints: Vec, + pub note: Option, +} + +#[derive(spacetimedb::SpacetimeType)] +pub struct PingCheckpoint { + pub sequence: u32, + pub state: PingState, +} + +#[derive(spacetimedb::SpacetimeType)] +pub enum PingState { + Ready, + Deferred(Option), +} + +#[spacetimedb::table(accessor = ping_log)] +pub struct PingLog { + #[primary_key] + #[auto_inc] + id: u64, + sender: spacetimedb::Identity, + module_version: u32, + body: String, +} + +#[spacetimedb::reducer] +pub fn receive_ping(ctx: &spacetimedb::ReducerContext, body: String) { + ctx.db.ping_log().insert(PingLog { + id: 0, + sender: ctx.sender(), + module_version: MODULE_VERSION, + body, + }); +} + +#[spacetimedb::table(accessor = detailed_ping_log)] +pub struct DetailedPingLog { + #[primary_key] + #[auto_inc] + id: u64, + body: String, + details: PingDetails, +} + +#[spacetimedb::reducer] +pub fn receive_detailed_ping(ctx: &spacetimedb::ReducerContext, body: String, details: PingDetails) { + ctx.db + .detailed_ping_log() + .insert(DetailedPingLog { id: 0, body, details }); +} diff --git a/crates/smoketests/modules/idc-self-outbox/Cargo.toml b/crates/smoketests/modules/idc-self-outbox/Cargo.toml new file mode 100644 index 00000000000..64585f10350 --- /dev/null +++ b/crates/smoketests/modules/idc-self-outbox/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-idc-self-outbox" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/idc-self-outbox/src/lib.rs b/crates/smoketests/modules/idc-self-outbox/src/lib.rs new file mode 100644 index 00000000000..c67179c8f49 --- /dev/null +++ b/crates/smoketests/modules/idc-self-outbox/src/lib.rs @@ -0,0 +1,77 @@ +#![allow(non_snake_case)] + +use spacetimedb::Table; + +#[spacetimedb::settings] +const CASE_CONVERSION_POLICY: spacetimedb::CaseConversionPolicy = spacetimedb::CaseConversionPolicy::None; + +#[derive(spacetimedb::SpacetimeType)] +pub struct Message { + pub ItemID: u64, + pub operations: Vec, +} + +#[derive(spacetimedb::SpacetimeType)] +pub enum Operation { + SetState(State), + ClearState, +} + +#[derive(spacetimedb::SpacetimeType)] +pub struct State { + pub revision: u64, + pub displayName: Option, +} + +#[spacetimedb::table(accessor = delivered_ping, name = "delivered_ping")] +pub struct DeliveredPing { + #[primary_key] + #[auto_inc] + id: u64, + body: Message, +} + +#[spacetimedb::reducer] +pub fn receivePing(ctx: &spacetimedb::ReducerContext, messageBody: Message) { + ctx.db.delivered_ping().insert(DeliveredPing { + id: 0, + body: messageBody, + }); +} + +#[spacetimedb::table(accessor = outboundPing, name = "outbound_ping", outbox(crate::receivePing))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: spacetimedb::Identity, + #[param(name = messageBody)] + payload: Message, +} + +#[spacetimedb::table(accessor = ping_results)] +pub struct PingResult { + #[primary_key] + id: u64, + body: Message, + error: Option, +} + +#[spacetimedb::reducer(name = "WirePingResult", on_result(self::outboundPing))] +pub fn onPingResult(ctx: &spacetimedb::ReducerContext, row: OutboundPing, result: Result<(), String>) { + ctx.db.ping_results().insert(PingResult { + id: row.id, + body: row.payload, + error: result.err(), + }); +} + +#[spacetimedb::reducer] +pub fn enqueuePing(ctx: &spacetimedb::ReducerContext, target: spacetimedb::Identity, messageBody: Message) { + ctx.db.outboundPing().insert(OutboundPing { + id: 0, + target, + payload: messageBody, + }); +} diff --git a/crates/smoketests/modules/idc-sender/Cargo.toml b/crates/smoketests/modules/idc-sender/Cargo.toml new file mode 100644 index 00000000000..9b4ac618508 --- /dev/null +++ b/crates/smoketests/modules/idc-sender/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-idc-sender" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/idc-sender/src/lib.rs b/crates/smoketests/modules/idc-sender/src/lib.rs new file mode 100644 index 00000000000..ca31cc45713 --- /dev/null +++ b/crates/smoketests/modules/idc-sender/src/lib.rs @@ -0,0 +1,134 @@ +mod remote_bindings; + +use remote_bindings::game_world; +use spacetimedb::Table; + +const MODULE_VERSION: u32 = 1; + +#[spacetimedb::table(accessor = ping_log)] +pub struct PingLog { + #[primary_key] + #[auto_inc] + id: u64, + sender: spacetimedb::Identity, + body: String, +} + +#[spacetimedb::reducer] +pub fn receive_ping(ctx: &spacetimedb::ReducerContext, body: String) { + ctx.db.ping_log().insert(PingLog { + id: 0, + sender: ctx.sender(), + body, + }); +} + +#[spacetimedb::table(accessor = outbound_ping, outbox(game_world::receive_ping))] +pub struct OutboundPing { + #[primary_key] + #[auto_inc] + message_number: u64, + #[target] + destination: game_world::Identity, + #[param(name = body)] + bodypo: String, +} + +#[spacetimedb::reducer] +pub fn enqueue_ping(ctx: &spacetimedb::ReducerContext, target: game_world::Identity, body: String) { + ctx.db.outbound_ping().insert(OutboundPing { + message_number: 0, + destination: target, + bodypo: body, + }); +} + +#[spacetimedb::reducer] +pub fn enqueue_pings(ctx: &spacetimedb::ReducerContext, target: game_world::Identity, bodies: Vec) { + for body in bodies { + enqueue_ping(ctx, target, body); + } +} + +#[spacetimedb::table(accessor = ping_result)] +pub struct PingResult { + #[primary_key] + id: u64, + #[auto_inc] + callback_order: u64, + module_version: u32, + body: String, + succeeded: bool, + error: Option, +} + +#[spacetimedb::reducer(on_result(self::outbound_ping))] +pub fn on_ping_result(ctx: &spacetimedb::ReducerContext, row: OutboundPing, result: Result<(), String>) { + ctx.db.ping_result().insert(PingResult { + id: row.message_number, + callback_order: 0, + module_version: MODULE_VERSION, + body: row.bodypo, + succeeded: result.is_ok(), + error: result.err(), + }); +} + +#[spacetimedb::table(accessor = outbound_detailed_ping, outbox(game_world::receive_detailed_ping))] +pub struct OutboundDetailedPing { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: game_world::Identity, + body: String, + details: game_world::PingDetails, +} + +#[spacetimedb::reducer] +pub fn enqueue_detailed_ping(ctx: &spacetimedb::ReducerContext, target: game_world::Identity, body: String) { + ctx.db.outbound_detailed_ping().insert(OutboundDetailedPing { + id: 0, + target, + body, + details: game_world::PingDetails { + labels: vec!["nested".into(), "idc".into()], + checkpoints: vec![ + game_world::PingCheckpoint { + sequence: 1, + state: game_world::PingState::Ready, + }, + game_world::PingCheckpoint { + sequence: 2, + state: game_world::PingState::Deferred(Some("waiting".into())), + }, + game_world::PingCheckpoint { + sequence: 3, + state: game_world::PingState::Deferred(None), + }, + ], + note: Some("optional-note".into()), + }, + }); +} + +#[spacetimedb::table(accessor = detailed_ping_result)] +pub struct DetailedPingResult { + #[primary_key] + id: u64, + body: String, + details: game_world::PingDetails, +} + +#[spacetimedb::reducer(on_result(self::outbound_detailed_ping))] +pub fn on_detailed_ping_result( + ctx: &spacetimedb::ReducerContext, + row: OutboundDetailedPing, + _result: Result<(), String>, +) { + ctx.db.detailed_ping_result().insert(DetailedPingResult { + id: row.id, + body: row.body, + details: row.details, + }); +} diff --git a/crates/smoketests/modules/idc-sender/src/remote_bindings/mod.rs b/crates/smoketests/modules/idc-sender/src/remote_bindings/mod.rs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/crates/smoketests/modules/idc-sender/src/remote_bindings/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/smoketests/modules/idc-two-dependencies-types/Cargo.toml b/crates/smoketests/modules/idc-two-dependencies-types/Cargo.toml new file mode 100644 index 00000000000..364a2fba3a8 --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies-types/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-idc-two-dependencies-types" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/idc-two-dependencies-types/src/lib.rs b/crates/smoketests/modules/idc-two-dependencies-types/src/lib.rs new file mode 100644 index 00000000000..3a4e1749a5a --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies-types/src/lib.rs @@ -0,0 +1,40 @@ +mod remote_bindings; + +use remote_bindings::{auth, game_world}; +use spacetimedb::Table; + +#[spacetimedb::table(accessor = world_message)] +pub struct WorldMessage { + #[primary_key] + #[auto_inc] + id: u64, + details: game_world::PingDetails, +} + +#[spacetimedb::table(accessor = auth_message)] +pub struct AuthMessage { + #[primary_key] + #[auto_inc] + id: u64, + details: auth::PingDetails, +} + +#[spacetimedb::reducer] +pub fn record_both(ctx: &spacetimedb::ReducerContext) { + ctx.db.world_message().insert(WorldMessage { + id: 0, + details: game_world::PingDetails { + labels: vec!["world".into()], + checkpoints: vec![], + note: None, + }, + }); + ctx.db.auth_message().insert(AuthMessage { + id: 0, + details: auth::PingDetails { + labels: vec!["auth".into()], + checkpoints: vec![], + note: None, + }, + }); +} diff --git a/crates/smoketests/modules/idc-two-dependencies-types/src/remote_bindings/mod.rs b/crates/smoketests/modules/idc-two-dependencies-types/src/remote_bindings/mod.rs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies-types/src/remote_bindings/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/smoketests/modules/idc-two-dependencies/Cargo.toml b/crates/smoketests/modules/idc-two-dependencies/Cargo.toml new file mode 100644 index 00000000000..35631d6352f --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-idc-two-dependencies" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/idc-two-dependencies/src/lib.rs b/crates/smoketests/modules/idc-two-dependencies/src/lib.rs new file mode 100644 index 00000000000..36a9c01379c --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies/src/lib.rs @@ -0,0 +1,50 @@ +mod remote_bindings; + +use remote_bindings::{auth, game_world}; +use spacetimedb::Table; + +#[spacetimedb::table(accessor = world_outbox, outbox(game_world::receive_detailed_ping))] +pub struct WorldOutbox { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: game_world::Identity, + body: String, + details: game_world::PingDetails, +} + +#[spacetimedb::table(accessor = auth_outbox, outbox(auth::receive_detailed_ping))] +pub struct AuthOutbox { + #[primary_key] + #[auto_inc] + id: u64, + #[target] + target: auth::Identity, + body: String, + details: auth::PingDetails, +} + +#[spacetimedb::reducer] +pub fn enqueue_both(ctx: &spacetimedb::ReducerContext, world: game_world::Identity, auth: auth::Identity) { + ctx.db.world_outbox().insert(WorldOutbox { + id: 0, + target: world, + body: "world".into(), + details: game_world::PingDetails { + labels: vec![], + checkpoints: vec![], + note: None, + }, + }); + ctx.db.auth_outbox().insert(AuthOutbox { + id: 0, + target: auth, + body: "auth".into(), + details: auth::PingDetails { + labels: vec![], + checkpoints: vec![], + note: None, + }, + }); +} diff --git a/crates/smoketests/modules/idc-two-dependencies/src/remote_bindings/mod.rs b/crates/smoketests/modules/idc-two-dependencies/src/remote_bindings/mod.rs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/crates/smoketests/modules/idc-two-dependencies/src/remote_bindings/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 423d4524e35..2b9285dcab7 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -189,6 +189,25 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } +/// Copies a Rust module fixture into a temporary project with a unique package name. +pub fn copy_module_fixture(name: &str, dest: &Path) -> Result<()> { + let source = workspace_root().join("crates/smoketests/modules").join(name); + fs::create_dir_all(dest.join("src"))?; + for file in ["Cargo.toml", "src/lib.rs"] { + fs::copy(source.join(file), dest.join(file)) + .with_context(|| format!("Failed to copy {file} from fixture {name}"))?; + } + let manifest_path = dest.join("Cargo.toml"); + let manifest = fs::read_to_string(&manifest_path)?; + let package_name = Regex::new(r#"(?m)^name\s*=\s*"([^"]+)""#)?; + let suffix = random_string(); + let manifest = package_name.replacen(&manifest, 1, |captures: ®ex::Captures<'_>| { + format!("name = \"{}-{suffix}\"", &captures[1]) + }); + fs::write(manifest_path, manifest.as_bytes())?; + Ok(()) +} + /// Rewrites `spacetimedb` dependency in `/Cargo.toml` to use local workspace bindings. pub fn patch_module_cargo_to_local_bindings(module_dir: &Path) -> Result<()> { let cargo_toml_path = module_dir.join("Cargo.toml"); @@ -202,7 +221,9 @@ pub fn patch_module_cargo_to_local_bindings(module_dir: &Path) -> Result<()> { let patched = cargo_toml .lines() .map(|line| { - if line.trim_start().starts_with("spacetimedb = ") { + if line.trim_start().starts_with("spacetimedb = ") + || line.trim_start().starts_with("spacetimedb.workspace = ") + { replacement.as_str() } else { line @@ -453,7 +474,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") endif() "#; -fn parse_identity_from_publish_output(publish_output: &str) -> Result { +/// Extracts the database identity from a single database publication. +pub fn parse_identity_from_publish_output(publish_output: &str) -> Result { let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); re.captures(publish_output) .and_then(|caps| caps.get(1)) @@ -1539,7 +1561,15 @@ log = "0.4" pub fn call(&self, name: &str, args: &[&str]) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; - let mut cmd_args = vec!["call", "--server", &self.server_url, "--", identity.as_str(), name]; + let mut cmd_args = vec![ + "call", + "--no-config", + "--server", + &self.server_url, + "--", + identity.as_str(), + name, + ]; cmd_args.extend(args); self.spacetime(&cmd_args) @@ -1549,7 +1579,15 @@ log = "0.4" pub fn call_output(&self, name: &str, args: &[&str]) -> Output { let identity = self.database_identity.as_ref().expect("No database published"); - let mut cmd_args = vec!["call", "--server", &self.server_url, "--", identity.as_str(), name]; + let mut cmd_args = vec![ + "call", + "--no-config", + "--server", + &self.server_url, + "--", + identity.as_str(), + name, + ]; cmd_args.extend(args); self.spacetime_cmd(&cmd_args) @@ -1561,6 +1599,7 @@ log = "0.4" let mut cmd_args = vec![ "call", + "--no-config", "--anonymous", "--server", &self.server_url, @@ -1943,6 +1982,33 @@ fn normalize_whitespace(s: &str) -> String { mod tests { use super::*; + #[test] + fn copied_module_fixtures_have_distinct_wasm_names() { + let temp = tempfile::tempdir().unwrap(); + let target_names: Vec<_> = ["first", "second"] + .into_iter() + .map(|directory| { + let module_dir = temp.path().join(directory); + copy_module_fixture("idc-receiver", &module_dir).unwrap(); + patch_module_cargo_to_local_bindings(&module_dir).unwrap(); + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(module_dir.join("Cargo.toml")) + .no_deps() + .exec() + .unwrap(); + let package = metadata.root_package().unwrap(); + package + .targets + .iter() + .find(|target| target.crate_types.iter().any(|kind| kind == "cdylib")) + .unwrap() + .name + .clone() + }) + .collect(); + assert_ne!(target_names[0], target_names[1]); + } + #[test] fn test_normalize_whitespace() { let input = "hello \nworld \n foo "; diff --git a/crates/smoketests/tests/cluster.rs b/crates/smoketests/tests/cluster.rs index f0aa305b01a..62ca5ff4ca7 100644 --- a/crates/smoketests/tests/cluster.rs +++ b/crates/smoketests/tests/cluster.rs @@ -23,6 +23,7 @@ mod cluster { mod filtering; mod http_egress; mod http_routes; + mod idc; mod logs_level_filter; mod module_nested_op; mod modules; diff --git a/crates/smoketests/tests/cluster/call.rs b/crates/smoketests/tests/cluster/call.rs index 74fdd8b20c0..8b235fb8c8e 100644 --- a/crates/smoketests/tests/cluster/call.rs +++ b/crates/smoketests/tests/cluster/call.rs @@ -1,5 +1,83 @@ use spacetimedb_smoketests::Smoketest; +#[test] +fn test_named_json_call_accepts_exported_canonical_parameter_names() { + use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10; + use spacetimedb_lib::sats::serde::SerdeWrapper; + use spacetimedb_lib::AlgebraicType; + + let test = Smoketest::builder() + .module_code( + r#"#![allow(non_snake_case)] +use spacetimedb::ReducerContext; + +#[derive(spacetimedb::SpacetimeType)] +pub struct Message { + Item: u64, +} + +#[spacetimedb::reducer] +pub fn receive(_ctx: &ReducerContext, MessageBody: Message) { + spacetimedb::log::info!("json-received:{}", MessageBody.Item); +} +"#, + ) + .build(); + let identity = test.database_identity.as_ref().unwrap(); + let schema = test + .api_call("GET", &format!("/v1/database/{identity}/schema?version=10")) + .unwrap(); + assert!(schema.is_success(), "schema request failed: {}", schema.text().unwrap()); + let SerdeWrapper(raw): SerdeWrapper = serde_json::from_slice(&schema.body).unwrap(); + let reducer = raw + .reducers() + .unwrap() + .iter() + .find(|reducer| reducer.source_name.as_ref() == "receive") + .unwrap(); + let param = &reducer.params.elements[0]; + let parameter_name = param.name.as_deref().unwrap(); + assert_eq!(parameter_name, "message_body"); + let AlgebraicType::Ref(message_ref) = param.algebraic_type else { + panic!("expected a named Message type"); + }; + let item_name = raw.typespace().unwrap()[message_ref].as_product().unwrap().elements[0] + .name + .as_deref() + .unwrap(); + assert_eq!(item_name, "item"); + + let path = format!("/v1/database/{identity}/call/receive"); + // Existing source-name requests and positional JSON both work, including a + // nested object whose field name comes from the exported canonical typespace. + for args in [ + serde_json::json!({ "MessageBody": { (item_name): 1 } }), + serde_json::json!([{ (item_name): 2 }]), + ] { + let response = test.api_call_json("POST", &path, &args.to_string()).unwrap(); + assert!( + response.is_success(), + "control call {args} failed: {}", + response.text().unwrap() + ); + } + + // A schema-driven caller uses the advertised parameter name, not the source name. + let args = serde_json::json!({ (parameter_name): { (item_name): 3 } }); + let response = test.api_call_json("POST", &path, &args.to_string()).unwrap(); + assert!( + response.is_success(), + "named JSON arguments built from the exported schema should be accepted: HTTP {}, {}", + response.status_code, + response.text().unwrap() + ); + assert!(test + .logs(100) + .unwrap() + .iter() + .any(|line| line.contains("json-received:3"))); +} + /// Check calling a reducer (no return) and procedure (return) #[test] fn test_call_reducer_procedure() { diff --git a/crates/smoketests/tests/cluster/idc.rs b/crates/smoketests/tests/cluster/idc.rs new file mode 100644 index 00000000000..1c3e9f7a2b1 --- /dev/null +++ b/crates/smoketests/tests/cluster/idc.rs @@ -0,0 +1,596 @@ +use std::time::{Duration, Instant}; + +use spacetimedb_smoketests::{ + copy_module_fixture, parse_identity_from_publish_output, patch_module_cargo_to_local_bindings, random_string, + Smoketest, +}; + +struct IdcTest { + test: Smoketest, + receiver: String, + sender: String, +} + +impl IdcTest { + fn new() -> Self { + let mut test = Smoketest::builder().autopublish(false).build(); + let receiver_db = format!("idc-receiver-{}", random_string()); + let sender_db = format!("idc-sender-{}", random_string()); + + for name in ["receiver", "sender"] { + let dir = test.project_dir.path().join(name); + copy_module_fixture(&format!("idc-{name}"), &dir).expect("failed to copy module fixture"); + patch_module_cargo_to_local_bindings(&dir).expect("failed to patch module Cargo.toml"); + } + std::fs::write( + test.project_dir.path().join("spacetime.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "server": test.server_url, + "database": receiver_db, + "module-path": "./receiver", + "children": [ + { + "database": sender_db, + "module-path": "./sender", + "dependencies": [{ "name": "game_world", "database": receiver_db }] + } + ] + })) + .unwrap(), + ) + .expect("failed to write spacetime.json"); + test.spacetime(&["generate", &sender_db]) + .expect("failed to generate receiver bindings"); + test.spacetime(&["publish", "--server", &test.server_url, "--yes=all"]) + .expect("failed to publish IDC modules"); + + let resolve_identity = |database: &str| { + let response = test + .api_call("GET", &format!("/v1/database/{database}/identity")) + .expect("failed to resolve database identity"); + assert!( + response.is_success(), + "identity lookup failed for {database}: HTTP {}", + response.status_code + ); + response.text().expect("invalid database identity response") + }; + let receiver = resolve_identity(&receiver_db); + let sender = resolve_identity(&sender_db); + test.database_identity = Some(sender.clone()); + Self { test, receiver, sender } + } + + fn publish(&self, module: &str, database: &str) -> String { + let output = self + .test + .spacetime(&[ + "publish", + "--no-config", + "--server", + &self.test.server_url, + "--module-path", + self.test.project_dir.path().join(module).to_str().unwrap(), + "--yes=all", + database, + ]) + .expect("failed to publish module update"); + parse_identity_from_publish_output(&output).expect("publish output should contain database identity") + } + + fn rows(&self, database: &str, sql: &str) -> Vec { + let output = self + .test + .spacetime(&[ + "sql", + "--no-config", + "--server", + &self.test.server_url, + "--format", + "json", + database, + sql, + ]) + .expect("failed to query database"); + let results: serde_json::Value = serde_json::from_str(&output).expect("invalid SQL JSON output"); + results[0]["rows"] + .as_array() + .expect("SQL result should contain rows") + .clone() + } + + fn enqueue(&self, bodies: &[String]) { + self.test + .call( + "enqueue_pings", + &[ + &serde_json::to_string(&self.receiver).unwrap(), + &serde_json::to_string(bodies).unwrap(), + ], + ) + .expect("failed to enqueue pings"); + } + + fn wait_for_messages(&self, expected: &[(String, u32)]) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + // The auto-increment columns record execution order, independently of message IDs. + let mut delivered = self.rows(&self.receiver, "SELECT id, body, module_version FROM ping_log"); + let mut callbacks = self.rows( + &self.sender, + "SELECT callback_order, body, module_version, succeeded, error FROM ping_result", + ); + delivered.sort_by_key(|row| row[0].as_u64().unwrap()); + callbacks.sort_by_key(|row| row[0].as_u64().unwrap()); + let pending = self.rows(&self.sender, "SELECT message_number FROM outbound_ping"); + assert!(delivered.len() <= expected.len(), "duplicate deliveries: {delivered:?}"); + assert!(callbacks.len() <= expected.len(), "duplicate callbacks: {callbacks:?}"); + if delivered.len() == expected.len() && callbacks.len() == expected.len() && pending.is_empty() { + for (index, (body, version)) in expected.iter().enumerate() { + assert_eq!(delivered[index][1], *body, "receiver delivery order"); + assert_eq!(delivered[index][2], *version, "receiver module version"); + assert_eq!(callbacks[index][1], *body, "sender callback order"); + assert_eq!(callbacks[index][2], *version, "sender module version"); + assert_eq!(callbacks[index][3], true, "callback failed: {:?}", callbacks[index]); + } + return; + } + assert!( + Instant::now() < deadline, + "IDC did not finish: delivered={delivered:?}, callbacks={callbacks:?}, pending={pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn send_ping_to(&self, receiver: &str, body: &str) { + self.test + .call( + "enqueue_ping", + &[ + &serde_json::to_string(receiver).unwrap(), + &serde_json::to_string(body).unwrap(), + ], + ) + .expect("failed to enqueue ping"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let callbacks = self.rows( + &self.sender, + &format!("SELECT succeeded FROM ping_result WHERE body = '{body}'"), + ); + let pending = self.rows(&self.sender, "SELECT message_number FROM outbound_ping"); + if !callbacks.is_empty() && pending.is_empty() { + assert_eq!(callbacks, vec![serde_json::json!([true])]); + assert_eq!( + self.rows(receiver, &format!("SELECT body FROM ping_log WHERE body = '{body}'")), + vec![serde_json::json!([body])], + ); + return; + } + assert!( + Instant::now() < deadline, + "ping {body} did not finish: {callbacks:?}, {pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn send_detailed_ping(&self, body: &str) { + self.test + .call( + "enqueue_detailed_ping", + &[ + &serde_json::to_string(&self.receiver).unwrap(), + &serde_json::to_string(body).unwrap(), + ], + ) + .expect("failed to enqueue detailed ping"); + let deadline = Instant::now() + Duration::from_secs(20); + let expected_details = serde_json::json!([ + ["nested", "idc"], + [[1, [0, []]], [2, [1, [0, "waiting"]]], [3, [1, [1, []]]]], + [0, "optional-note"] + ]); + loop { + let callbacks = self.rows( + &self.sender, + &format!("SELECT details FROM detailed_ping_result WHERE body = '{body}'"), + ); + let pending = self.rows(&self.sender, "SELECT id FROM outbound_detailed_ping"); + let delivered = self.rows( + &self.receiver, + &format!("SELECT details FROM detailed_ping_log WHERE body = '{body}'"), + ); + if !callbacks.is_empty() && pending.is_empty() { + assert_eq!(delivered, vec![serde_json::json!([expected_details])]); + assert_eq!(callbacks, vec![serde_json::json!([expected_details])]); + return; + } + assert!( + Instant::now() < deadline, + "detailed IDC ping did not finish: delivered={delivered:?}, callbacks={callbacks:?}, pending={pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn edit_module(&self, module: &str, from: &str, to: &str) { + let path = self.test.project_dir.path().join(module).join("src/lib.rs"); + let source = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n"); + assert!(source.contains(from), "missing source in {module}: {from}"); + std::fs::write(&path, source.replace(from, to)).unwrap(); + } + + fn update_modules(&self, version: u32) { + for (module, database) in [("receiver", &self.receiver), ("sender", &self.sender)] { + self.edit_module( + module, + &format!("const MODULE_VERSION: u32 = {};", version - 1), + &format!("const MODULE_VERSION: u32 = {version};"), + ); + assert_eq!(self.publish(module, database), *database); + } + } +} + +#[test] +fn idc_outbox_delivers_between_two_rust_modules() { + let suite = IdcTest::new(); + suite + .test + .call( + "enqueue_ping", + &[&serde_json::to_string(&suite.receiver).unwrap(), "\"hello-idc\""], + ) + .expect("failed to enqueue ping"); + suite.wait_for_messages(&[("hello-idc".into(), 1)]); +} + +#[test] +fn idc_outbox_delivers_twenty_messages_in_order() { + let suite = IdcTest::new(); + let bodies: Vec<_> = (0..20).map(|index| format!("message-{index}")).collect(); + suite.enqueue(&bodies); + suite.wait_for_messages(&bodies.into_iter().map(|body| (body, 1)).collect::>()); +} + +#[test] +fn idc_outbox_delivers_to_two_databases_with_the_same_receiver_module() { + let suite = IdcTest::new(); + let second_receiver = suite.publish("receiver", &format!("idc-receiver-{}", random_string())); + assert_ne!(suite.receiver, second_receiver); + + // The same bindings work for both databases before either receiver changes. + suite.send_ping_to(&suite.receiver, "first-receiver"); + suite.send_ping_to(&second_receiver, "second-receiver"); + + // Update only the first receiver. Existing rows get revision 0; new calls supply it. + suite.edit_module( + "receiver", + " module_version: u32,\n body: String,", + " module_version: u32,\n body: String,\n #[default(0)]\n revision: u32,", + ); + suite.edit_module( + "receiver", + "pub fn receive_ping(ctx: &spacetimedb::ReducerContext, body: String)", + "pub fn receive_ping(ctx: &spacetimedb::ReducerContext, body: String, revision: u32)", + ); + suite.edit_module("receiver", " body,\n", " body,\n revision,\n"); + assert_eq!(suite.publish("receiver", &suite.receiver), suite.receiver); + assert_eq!( + suite.rows(&suite.receiver, "SELECT body, revision FROM ping_log"), + vec![serde_json::json!(["first-receiver", 0])], + ); + + // Regenerate the first receiver's bindings and add the new argument to the sender's outbox. + suite + .test + .spacetime(&["generate"]) + .expect("failed to regenerate receiver bindings"); + suite.edit_module( + "sender", + " bodypo: String,", + " bodypo: String,\n #[default(0)]\n revision: u32,", + ); + suite.edit_module( + "sender", + " bodypo: body,", + " bodypo: body,\n revision: 7,", + ); + assert_eq!(suite.publish("sender", &suite.sender), suite.sender); + suite.send_ping_to(&suite.receiver, "after-update"); + assert_eq!( + suite.rows( + &suite.receiver, + "SELECT revision FROM ping_log WHERE body = 'after-update'" + ), + vec![serde_json::json!([7])], + ); + assert_eq!( + suite.rows(&second_receiver, "SELECT body FROM ping_log"), + vec![serde_json::json!(["second-receiver"])], + ); +} + +#[test] +fn idc_outboxes_deliver_in_both_directions_concurrently() { + let suite = IdcTest::new(); + let peer = suite.publish("sender", &format!("idc-peer-{}", random_string())); + let from_sender: Vec<_> = (0..20).map(|index| format!("sender-{index}")).collect(); + let from_peer: Vec<_> = (0..20).map(|index| format!("peer-{index}")).collect(); + + // Release both calls together, without waiting for either direction's deliveries. + let start = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + for (source, target, bodies) in [(&suite.sender, &peer, &from_sender), (&peer, &suite.sender, &from_peer)] { + let (test, start) = (&suite.test, &start); + scope.spawn(move || { + start.wait(); + test.spacetime(&[ + "call", + "--no-config", + "--server", + &test.server_url, + "--", + source, + "enqueue_pings", + &serde_json::to_string(target).unwrap(), + &serde_json::to_string(bodies).unwrap(), + ]) + .expect("failed to enqueue concurrent pings"); + }); + } + }); + + for (database, incoming, outgoing) in [ + (&suite.sender, &from_peer, &from_sender), + (&peer, &from_sender, &from_peer), + ] { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let mut delivered = suite.rows(database, "SELECT id, body FROM ping_log"); + let mut callbacks = suite.rows(database, "SELECT callback_order, body, succeeded FROM ping_result"); + let pending = suite.rows(database, "SELECT message_number FROM outbound_ping"); + assert!(delivered.len() <= incoming.len(), "duplicate deliveries: {delivered:?}"); + assert!(callbacks.len() <= outgoing.len(), "duplicate callbacks: {callbacks:?}"); + if delivered.len() == incoming.len() && callbacks.len() == outgoing.len() && pending.is_empty() { + delivered.sort_by_key(|row| row[0].as_u64().unwrap()); + callbacks.sort_by_key(|row| row[0].as_u64().unwrap()); + for (row, body) in delivered.iter().zip(incoming) { + assert_eq!(row[1], *body, "delivery order for {database}"); + } + for (row, body) in callbacks.iter().zip(outgoing) { + assert_eq!(row[1], *body, "callback order for {database}"); + assert_eq!(row[2], true, "callback failed: {row:?}"); + } + break; + } + assert!( + Instant::now() < deadline, + "bidirectional IDC did not finish for {database}: delivered={delivered:?}, callbacks={callbacks:?}, pending={pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +#[test] +fn idc_outbox_preserves_order_across_module_updates() { + let suite = IdcTest::new(); + let mut expected = Vec::new(); + for version in 1..=4 { + if version == 2 { + // Rename the Rust accessor while preserving the canonical name used by the old bindings. + suite.edit_module( + "receiver", + "#[spacetimedb::reducer]\npub fn receive_ping", + "#[spacetimedb::reducer(name = \"receive_ping\")]\npub fn accept_ping", + ); + } + if version > 1 { + suite.update_modules(version); + } + let bodies: Vec<_> = (expected.len()..expected.len() + 5) + .map(|index| format!("message-{index}")) + .collect(); + suite.enqueue(&bodies); + expected.extend(bodies.into_iter().map(|body| (body, version))); + suite.wait_for_messages(&expected); + } +} + +#[test] +fn idc_outbox_uses_regenerated_bindings_after_argument_reordering() { + let suite = IdcTest::new(); + suite.send_detailed_ping("before-update"); + + // Changing parameter order changes the signature hash and the outbox's argument-column mapping. + // The stored row types stay the same, so both databases can be updated without clearing data. + suite.edit_module( + "receiver", + "body: String, details: PingDetails", + "details: PingDetails, body: String", + ); + suite + .test + .spacetime(&["generate"]) + .expect("failed to regenerate receiver bindings"); + assert_eq!(suite.publish("receiver", &suite.receiver), suite.receiver); + // The regenerated bindings map the unchanged sender columns into the new argument order. + assert_eq!(suite.publish("sender", &suite.sender), suite.sender); + + suite.send_detailed_ping("after-update"); +} + +#[test] +fn idc_self_outbox_uses_none_case_policy() { + let mut test = Smoketest::builder().autopublish(false).build(); + let database = format!("idc-self-{}", random_string()); + let module_dir = test.project_dir.path().join("self_outbox"); + copy_module_fixture("idc-self-outbox", &module_dir).expect("failed to copy self-outbox fixture"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + + let output = test + .spacetime(&[ + "publish", + "--no-config", + "--server", + &test.server_url, + "--module-path", + module_dir.to_str().unwrap(), + "--yes=all", + &database, + ]) + .expect("failed to publish self-outbox module"); + let identity = parse_identity_from_publish_output(&output).expect("publish output should contain an identity"); + test.database_identity = Some(identity.clone()); + let message = serde_json::json!([42, [[0, [7, [0, "self-message"]]], [0, [8, [1, []]]], [1, []]]]); + test.call( + "enqueuePing", + &[&serde_json::to_string(&identity).unwrap(), &message.to_string()], + ) + .expect("failed to enqueue self-referential message"); + + let rows = |sql: &str| { + let output = test + .spacetime(&[ + "sql", + "--no-config", + "--server", + &test.server_url, + "--format", + "json", + &database, + sql, + ]) + .expect("failed to query self-outbox module"); + let result: serde_json::Value = serde_json::from_str(&output).expect("invalid SQL JSON output"); + result[0]["rows"].as_array().unwrap().clone() + }; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let callbacks = rows("SELECT body, error FROM ping_results"); + let pending = rows("SELECT id FROM outbound_ping"); + let delivered = rows("SELECT body FROM delivered_ping"); + if !callbacks.is_empty() && pending.is_empty() { + assert_eq!(delivered, vec![serde_json::json!([message])]); + assert_eq!(callbacks, vec![serde_json::json!([message, [1, []]])]); + break; + } + assert!( + Instant::now() < deadline, + "self IDC did not finish: delivered={delivered:?}, callbacks={callbacks:?}, pending={pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[test] +fn idc_generated_receiver_types_are_scoped_by_dependency() { + let mut test = Smoketest::builder().autopublish(false).build(); + let world_db = format!("idc-world-{}", random_string()); + let auth_db = format!("idc-auth-{}", random_string()); + let sender_db = format!("idc-scoped-sender-{}", random_string()); + + for (fixture, directory) in [ + ("idc-receiver", "world"), + ("idc-receiver", "auth"), + ("idc-two-dependencies", "sender"), + ] { + let module_dir = test.project_dir.path().join(directory); + copy_module_fixture(fixture, &module_dir).expect("failed to copy IDC fixture"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + } + std::fs::write( + test.project_dir.path().join("spacetime.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "server": test.server_url, + "database": world_db, + "module-path": "./world", + "children": [ + { "database": auth_db, "module-path": "./auth" }, + { + "database": sender_db, + "module-path": "./sender", + "dependencies": [ + { "name": "game_world", "database": world_db }, + { "name": "auth", "database": auth_db } + ] + } + ] + })) + .unwrap(), + ) + .expect("failed to write spacetime.json"); + + test.spacetime(&["generate", &sender_db]) + .expect("failed to generate both receiver bindings"); + for dependency in ["game_world", "auth"] { + let file = test + .project_dir + .path() + .join("sender/src/remote_bindings") + .join(dependency) + .join("ping_details_type.rs"); + let source = std::fs::read_to_string(file).expect("generated receiver type is missing"); + assert!(source.contains(&format!("#[sats(name = \"{dependency}.PingDetails\")]"))); + } + test.spacetime(&["publish", "--server", &test.server_url, "--yes=all"]) + .expect("failed to publish modules with same-named receiver types"); + + let resolve_identity = |database: &str| { + let response = test + .api_call("GET", &format!("/v1/database/{database}/identity")) + .expect("failed to resolve database identity"); + assert!(response.is_success(), "identity lookup failed for {database}"); + response.text().expect("invalid database identity response") + }; + let world = resolve_identity(&world_db); + let auth = resolve_identity(&auth_db); + test.database_identity = Some(resolve_identity(&sender_db)); + test.call( + "enqueue_both", + &[ + &serde_json::to_string(&world).unwrap(), + &serde_json::to_string(&auth).unwrap(), + ], + ) + .expect("failed to enqueue both remote messages"); + + let rows = |database: &str, sql: &str| { + let output = test + .spacetime(&[ + "sql", + "--no-config", + "--server", + &test.server_url, + "--format", + "json", + database, + sql, + ]) + .expect("failed to query IDC database"); + let result: serde_json::Value = serde_json::from_str(&output).expect("invalid SQL JSON output"); + result[0]["rows"].as_array().unwrap().clone() + }; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let world_messages = rows(&world_db, "SELECT body FROM detailed_ping_log"); + let auth_messages = rows(&auth_db, "SELECT body FROM detailed_ping_log"); + let world_pending = rows(&sender_db, "SELECT id FROM world_outbox"); + let auth_pending = rows(&sender_db, "SELECT id FROM auth_outbox"); + if world_messages == vec![serde_json::json!(["world"])] + && auth_messages == vec![serde_json::json!(["auth"])] + && world_pending.is_empty() + && auth_pending.is_empty() + { + break; + } + assert!( + Instant::now() < deadline, + "scoped IDC did not finish: world={world_messages:?}, auth={auth_messages:?}, world_pending={world_pending:?}, auth_pending={auth_pending:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} diff --git a/crates/smoketests/tests/standalone/cli/generate.rs b/crates/smoketests/tests/standalone/cli/generate.rs index 39d3c5a95a8..21c3f820dce 100644 --- a/crates/smoketests/tests/standalone/cli/generate.rs +++ b/crates/smoketests/tests/standalone/cli/generate.rs @@ -1,6 +1,6 @@ use predicates::prelude::*; use spacetimedb_guard::ensure_binaries_built; -use spacetimedb_smoketests::patch_module_cargo_to_local_bindings; +use spacetimedb_smoketests::{copy_module_fixture, patch_module_cargo_to_local_bindings}; use std::process::Command; fn cli_cmd() -> Command { @@ -90,3 +90,202 @@ fn cli_generate_with_config_but_no_match_uses_cli_args() { "Generated files should exist in CLI-specified output directory" ); } + +/// Standalone-only: receiver/module bindings are generated from a local Rust module without contacting a server. +#[test] +fn cli_generate_rust_module_bindings() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + let output = cli_cmd() + .args([ + "init", + "--non-interactive", + "--lang", + "rust", + "--project-path", + temp_dir.path().to_str().unwrap(), + "test-project", + ]) + .current_dir(temp_dir.path()) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let module_dir = temp_dir.path().join("spacetimedb"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + + let output = cli_cmd() + .args(["build", "--module-path", module_dir.to_str().unwrap()]) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let output_dir = module_dir.join("receiver-bindings"); + let output = cli_cmd() + .args([ + "generate", + "--lang", + "rust", + "--bindings", + "module", + "--out-dir", + output_dir.to_str().unwrap(), + "--module-path", + module_dir.to_str().unwrap(), + ]) + .current_dir(&module_dir) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "generate failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let root = std::fs::read_to_string(output_dir.join("lib.rs")).expect("generated lib.rs should exist"); + assert!(root.contains("pub struct Identity(pub spacetimedb::Identity);")); + assert!(root.contains("impl spacetimedb::rt::RemoteReducer for add")); + assert!(root.contains("const NAME: &'static str = \"add\";")); + assert!(!root.contains("DbConnection")); + assert!(!output_dir.join("add_reducer.rs").exists()); +} + +#[test] +fn cli_generate_two_scoped_receiver_types_compile_in_sender() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + for (name, fixture) in [ + ("world", "idc-receiver"), + ("auth", "idc-receiver"), + ("sender", "idc-two-dependencies-types"), + ] { + let module_dir = temp_dir.path().join(name); + copy_module_fixture(fixture, &module_dir).expect("failed to copy module fixture"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + } + std::fs::write( + temp_dir.path().join("spacetime.json"), + r#"{ + "server": "local", + "children": [ + { "database": "world", "module-path": "./world" }, + { "database": "auth", "module-path": "./auth" }, + { + "database": "sender", + "module-path": "./sender", + "dependencies": [ + { "name": "game_world", "database": "world" }, + { "name": "auth", "database": "auth" } + ] + } + ] +}"#, + ) + .expect("failed to write spacetime.json"); + + let output = cli_cmd() + .args(["generate", "sender"]) + .current_dir(temp_dir.path()) + .output() + .expect("failed to execute generate"); + assert!( + output.status.success(), + "generate failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + for dependency in ["game_world", "auth"] { + let path = temp_dir + .path() + .join("sender/src/remote_bindings") + .join(dependency) + .join("ping_details_type.rs"); + let source = std::fs::read_to_string(path).expect("generated receiver type is missing"); + assert!(source.contains(&format!("#[sats(name = \"{dependency}.PingDetails\")]"))); + } + let sender_dir = temp_dir.path().join("sender"); + let output = cli_cmd() + .args(["build", "--module-path", sender_dir.to_str().unwrap()]) + .output() + .expect("failed to execute build"); + assert!( + output.status.success(), + "sender build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} +/// Standalone-only: a sender module can use generated receiver bindings from a spacetime.json dependency. +#[test] +fn cli_generate_receiver_bindings_from_dependency_compile_in_sender() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + let receiver_dir = temp_dir.path().join("receiver"); + let sender_dir = temp_dir.path().join("sender"); + + copy_module_fixture("idc-receiver", &receiver_dir).expect("failed to copy receiver fixture"); + copy_module_fixture("idc-sender", &sender_dir).expect("failed to copy sender fixture"); + patch_module_cargo_to_local_bindings(&receiver_dir).expect("failed to patch receiver Cargo.toml"); + patch_module_cargo_to_local_bindings(&sender_dir).expect("failed to patch sender Cargo.toml"); + + std::fs::write( + temp_dir.path().join("spacetime.json"), + r#"{ + "server": "local", + "children": [ + { + "database": "game-world", + "module-path": "./receiver" + }, + { + "database": "lobby", + "module-path": "./sender", + "dependencies": [ + { "name": "game_world", "database": "game-world" } + ] + } + ] +} +"#, + ) + .expect("failed to write spacetime.json"); + + let output = cli_cmd() + .args(["generate", "lobby"]) + .current_dir(temp_dir.path()) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "generate failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let remote_bindings_mod = std::fs::read_to_string(sender_dir.join("src/remote_bindings/mod.rs")) + .expect("generated remote_bindings mod.rs should exist"); + assert!(remote_bindings_mod.contains("pub mod game_world;")); + + let generated = sender_dir.join("src/remote_bindings/game_world/mod.rs"); + let root = std::fs::read_to_string(&generated).expect("generated receiver binding should exist"); + assert!(root.contains("pub struct Identity(pub spacetimedb::Identity);")); + assert!(root.contains("impl spacetimedb::rt::RemoteReducer for receive_ping")); + assert!(root.contains("receive_ping")); + + let output = cli_cmd() + .args(["build", "--module-path", sender_dir.to_str().unwrap()]) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "sender build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/testing/tests/idc_actor.rs b/crates/testing/tests/idc_actor.rs index f48860cb336..f96f2abbf34 100644 --- a/crates/testing/tests/idc_actor.rs +++ b/crates/testing/tests/idc_actor.rs @@ -255,9 +255,7 @@ fn outbox_schema(name: &str) -> TableSchema { &AlgebraicType::unit(), &AlgebraicType::String, &spacetimedb_sats::Typespace::default(), - ) - .to_hex() - .to_string(), + ), }), ) }