From b3904cee169c8ef4ff5bab7d448f479a32bb958d Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sun, 25 Oct 2026 17:18:50 +0530 Subject: [PATCH 01/30] Preserve canonical names in V10 schema roundtrips --- crates/schema/src/def.rs | 132 ++++++++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 8b59bb469de..e4697977e3a 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -1108,7 +1108,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(), @@ -1204,19 +1208,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)); } @@ -2342,6 +2357,7 @@ impl From for RawViewDefV10 { is_anonymous, is_public, params, + params_for_generate, return_type, fn_ptr, .. @@ -2351,7 +2367,7 @@ impl From for RawViewDefV10 { index: fn_ptr.into(), is_public, is_anonymous, - params, + params: canonical_params(params, ¶ms_for_generate), return_type, } } @@ -2443,14 +2459,20 @@ 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) } } +// 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()); + } + params +} + 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. @@ -2536,7 +2558,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 +2646,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 +2902,88 @@ 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] + 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; From 0000e90e0c6382e6881d2ada24ed07ad01622a48 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sun, 25 Oct 2026 17:19:00 +0530 Subject: [PATCH 02/30] Use typed hashes for IDC outbox signatures --- .../Internal/Autogen/RawOutboxDefV10.g.cs | 5 +-- crates/core/src/host/idc_actor.rs | 11 ++--- crates/core/src/sql/execute.rs | 2 +- .../src/locking_tx_datastore/datastore.rs | 4 +- .../src/locking_tx_datastore/state_view.rs | 9 +++- .../src/locking_tx_datastore/tx_state.rs | 1 - crates/datastore/src/system_tables/idc.rs | 42 ++++++++++++++++--- crates/engine/src/update.rs | 4 +- crates/lib/src/db/raw_def/v10.rs | 7 ++-- crates/schema/src/auto_migrate.rs | 2 +- crates/schema/src/def.rs | 4 +- crates/schema/src/def/validate/v10.rs | 8 ++-- crates/schema/src/schema.rs | 5 ++- crates/testing/tests/idc_actor.rs | 4 +- 14 files changed, 72 insertions(+), 36 deletions(-) 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/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..5d31d4073df 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"), ); } @@ -1109,7 +1109,7 @@ mod test { 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..10d992dbba8 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; @@ -367,7 +368,7 @@ 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, } /// A reducer definition. @@ -1348,7 +1349,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,7 +1357,7 @@ 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, }); } diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 12acc9c8d73..7ebe5a77f30 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -1430,7 +1430,7 @@ mod tests { 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 e4697977e3a..2711bbb47f0 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -46,7 +46,7 @@ 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_bytes, Hash as SpacetimeHash, ProductType, RawModuleDef}; use spacetimedb_primitives::{ ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, }; @@ -1421,7 +1421,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 { diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 8e96422fedb..7167a48fad0 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1433,6 +1433,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 +1754,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 +1770,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 +1802,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(); 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/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(), + ), }), ) } From 88d1d9d5cd1a0b6f66b6a914deef63c710eabd0a Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 07:01:39 +0530 Subject: [PATCH 03/30] deserialization test --- crates/schema/src/def.rs | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 2711bbb47f0..6fc6a5711da 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -2956,6 +2956,81 @@ mod tests { } } + #[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}; From 4aae4328da188b219d9db52f96d52bbbdf2238c4 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 18:36:54 +0530 Subject: [PATCH 04/30] update deserializer to check with both canonical and accessor names --- Cargo.lock | 1 + crates/sats/src/de.rs | 2 +- crates/sats/src/de/impls.rs | 19 ++- crates/schema/Cargo.toml | 1 + crates/schema/src/def/deserialize.rs | 157 +++++++++++++++++++++++- crates/smoketests/tests/cluster/call.rs | 78 ++++++++++++ 6 files changed, 254 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36065e3f92a..a93d1ceb934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8940,6 +8940,7 @@ dependencies = [ "lean_string", "petgraph 0.6.5", "proptest", + "serde_json", "serial_test", "smallvec", "spacetimedb-data-structures", 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/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/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() { From 61496192e28de0723d4f3756d65b27e6d4c41304 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:39:23 +0530 Subject: [PATCH 05/30] Validate IDC outbox callbacks and preserve raw schema round trips --- crates/engine/src/update.rs | 3 + crates/lib/src/db/raw_def/v10.rs | 22 +++ crates/schema/src/auto_migrate.rs | 3 + crates/schema/src/def.rs | 11 +- crates/schema/src/def/validate/v10.rs | 201 +++++++++++++++++++++++--- crates/schema/src/error.rs | 14 ++ 6 files changed, 231 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index 5d31d4073df..8b26299d724 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -1103,6 +1103,9 @@ mod test { }; table.finish(); + if let Some(on_result) = on_result { + builder.add_reducer(on_result, ProductType::unit()); + } builder.add_outbox( "outbound_pings", "receive_ping", diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 10d992dbba8..826734c8748 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -1361,6 +1361,28 @@ impl RawModuleDefV10Builder { }); } + 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()); + if let Some(outbox) = self + .outboxes_mut() + .iter_mut() + .find(|outbox| outbox.table_name == table_name) + { + assert!( + outbox.on_result_reducer.is_none(), + "outbox table `{table_name}` has multiple on_result reducers" + ); + outbox.on_result_reducer = on_result_reducer; + } else { + panic!("on_result reducer references unknown outbox table `{table_name}`"); + } + } + pub fn add_submodule(&mut self, namespace: impl Into, module: RawModuleDefV10) { let submodule = RawSubmoduleV10 { namespace: namespace.into(), diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 7ebe5a77f30..1f449147df3 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -1424,6 +1424,9 @@ 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::unit()); + } builder.add_outbox( "outbound_pings", remote_reducer.to_owned(), diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 6fc6a5711da..607c4b42e43 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -324,6 +324,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(); @@ -1166,7 +1173,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, }); } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 7167a48fad0..740e2b87428 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -279,10 +279,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, + 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 +302,7 @@ 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)?; + 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)?; @@ -1192,15 +1203,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 +1259,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 +1272,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 +1282,39 @@ 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) { + 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 +1462,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; @@ -2560,6 +2601,122 @@ mod tests { }); } + fn outbox_with_on_result() -> RawModuleDefV10Builder { + let mut builder = RawModuleDefV10Builder::new(); + 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::unit()); + 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_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/error.rs b/crates/schema/src/error.rs index 391db8584cd..56bb696ea3f 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -152,6 +152,20 @@ 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("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} 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")] From 028d9aec6158a8f2af31053035d11bae76bf6acd Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:39:58 +0530 Subject: [PATCH 06/30] Generate Rust IDC receiver bindings and reducer metadata --- crates/bindings/src/rt.rs | 16 + crates/codegen/src/lib.rs | 27 + crates/codegen/src/rust.rs | 480 ++++++++- crates/codegen/tests/codegen.rs | 60 +- ...codegen__codegen_rust_module_bindings.snap | 990 ++++++++++++++++++ 5 files changed, 1540 insertions(+), 33 deletions(-) create mode 100644 crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index b946487cdc0..71b70bf65ad 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -180,6 +180,22 @@ 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 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 tuple, excluding the reducer context. + type Args; +} + pub trait Procedure<'de, A: Args<'de>, Ret: IntoProcedureResult> { fn invoke(&self, ctx: &mut ProcedureContext, args: A) -> Ret; } diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index ef8990964ac..4156d4c566b 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -30,6 +30,33 @@ impl Default for CodegenOptions { } } +/// Target language and language-specific options for receiver bindings. +pub enum ReceiverLanguage<'a> { + Rust, + TypeScript, + Csharp { namespace: &'a str }, + Cpp, +} + +/// Generate receiver binding files without reading or writing the filesystem. +pub fn generate_receiver_bindings(module: &ModuleDef, lang: ReceiverLanguage<'_>) -> anyhow::Result> { + match lang { + ReceiverLanguage::Rust => Ok(rust::generate_rust_module_bindings(module)), + 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..b05db7734d2 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -27,6 +27,16 @@ const INDENT: &str = " "; pub struct Rust; +pub(super) fn generate_rust_module_bindings(module: &ModuleDef) -> 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)), + [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 +51,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 +523,7 @@ pub(super) fn parse_table_update( &args_type, &reducer.params_for_generate.elements, "pub(super)", + TypeContext::Client, ); out.newline(); @@ -645,6 +656,7 @@ impl {func_name} for super::RemoteReducers {{ &procedure.params_for_generate.elements, // non-pub visibility. "", + TypeContext::Client, ); out.newline(); @@ -709,7 +721,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,26 +993,47 @@ 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::"), + }; 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::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_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_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 { @@ -1015,19 +1048,19 @@ pub fn write_type(module: &ModuleDef, out: &mut W, ty: &AlgebraicTypeU 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::I256 => write!(out, "{sats_path}::i256")?, + PrimitiveType::U256 => write!(out, "{sats_path}::u256")?, PrimitiveType::F32 => write!(out, "f32")?, PrimitiveType::F64 => write!(out, "f64")?, }, AlgebraicTypeUse::String => write!(out, "String")?, AlgebraicTypeUse::Array(elem_ty) => { write!(out, "Vec::<")?; - write_type(module, out, elem_ty)?; + 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 +1089,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 +1122,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 +1176,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 +1186,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 +1205,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 +1219,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 +1235,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 +1245,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 +1276,7 @@ fn define_struct_for_product( name: &str, elements: &[(Identifier, AlgebraicTypeUse)], vis: &str, + context: TypeContext, ) { print_struct_derives(out); @@ -1238,10 +1284,296 @@ 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) -> 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 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(); + print_module_binding_remote_module(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_remote_module(out: &mut Indenter) { + writeln!( + out, + " +#[doc(hidden)] +#[derive(Debug)] +pub struct RemoteModule; + +" + ); +} + +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} {{ + 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 +1637,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 +1690,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 +2604,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, + [ + "Option::>>", + "Result::", + "Option::>", + "Result::", + "Result::", + ], + ), + ]; + 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..01800202cc1 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,62 @@ 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) + .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 test_typescript_table_handles_are_camel_case() { let module = compiled_module(); 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..591333eaa18 --- /dev/null +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap @@ -0,0 +1,990 @@ +--- +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 _: 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 _: 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 { + 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 _: 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 { + 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 _: 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 { + 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 { + 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 _: 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 { + 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 _: 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 { + 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 _: 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 _: Option:: = 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 { + 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 _: 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const NAME: &'static str = "test_btree_index_args"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = TestBtreeIndexArgsArgs; +} From 0b66f6c9a9b0c1f80763c13e2136cfc607a3084d Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:40:07 +0530 Subject: [PATCH 07/30] Wire Rust receiver binding generation into spacetime generate --- crates/cli/src/subcommands/generate.rs | 120 +++++++++++++++++-------- 1 file changed, 84 insertions(+), 36 deletions(-) diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index a92f4bb1ff0..05d854ba1bd 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; @@ -647,28 +647,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 +758,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)?; + 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 +1460,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 +1504,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 +1517,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"); + } } } From d2f9396a680a1e48ac6ca714d8d0135f52ce364f Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:52:39 +0530 Subject: [PATCH 08/30] Scope generated receiver types by dependency --- crates/cli/src/subcommands/generate.rs | 2 +- crates/codegen/src/lib.rs | 10 ++++- crates/codegen/src/rust.rs | 28 +++++-------- crates/codegen/tests/codegen.rs | 58 +++++++++++++++++++++++++- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 05d854ba1bd..e0bc5c973d8 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -767,7 +767,7 @@ pub async fn run_prepared_generate_configs( Language::Cpp => ReceiverLanguage::Cpp, Language::UnrealCpp => anyhow::bail!("Unreal C++ is a client binding target"), }; - let files = generate_receiver_bindings(&module, lang)?; + let files = generate_receiver_bindings(&module, lang, run.dependency_name.as_deref())?; if run.lang == Language::Rust && let Some(name) = &run.dependency_name { diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 4156d4c566b..157a927bdfd 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -39,9 +39,15 @@ pub enum ReceiverLanguage<'a> { } /// Generate receiver binding files without reading or writing the filesystem. -pub fn generate_receiver_bindings(module: &ModuleDef, lang: ReceiverLanguage<'_>) -> anyhow::Result> { +/// `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)), + 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") diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index b05db7734d2..03b2192b955 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -27,11 +27,15 @@ const INDENT: &str = " "; pub struct Rust; -pub(super) fn generate_rust_module_bindings(module: &ModuleDef) -> Vec { +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)), + 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() @@ -1290,7 +1294,7 @@ fn define_struct_for_product( out.newline(); } -fn generate_module_binding_type_file(module: &ModuleDef, typ: &TypeDef) -> OutputFile { +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; @@ -1315,6 +1319,10 @@ fn generate_module_binding_type_file(module: &ModuleDef, typ: &TypeDef) -> Outpu 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); } @@ -1356,8 +1364,6 @@ fn generate_module_bindings_root_file(module: &ModuleDef, type_refs: &BTreeSet spacetimedb::Deserialize<'de> for Identity {{ ); } -fn print_module_binding_remote_module(out: &mut Indenter) { - writeln!( - out, - " -#[doc(hidden)] -#[derive(Debug)] -pub struct RemoteModule; - -" - ); -} - 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}"); diff --git a/crates/codegen/tests/codegen.rs b/crates/codegen/tests/codegen.rs index 01800202cc1..a1f7a5c8d08 100644 --- a/crates/codegen/tests/codegen.rs +++ b/crates/codegen/tests/codegen.rs @@ -50,7 +50,7 @@ declare_tests! { #[test] fn test_codegen_rust_module_bindings() { let module = compiled_module(); - let outfiles = generate_receiver_bindings(module, ReceiverLanguage::Rust) + let outfiles = generate_receiver_bindings(module, ReceiverLanguage::Rust, None) .unwrap() .into_iter() .map(|f| (f.filename, f.code)) @@ -103,6 +103,62 @@ fn test_codegen_rust_module_bindings() { 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_typescript_table_handles_are_camel_case() { let module = compiled_module(); From d7c17b3b80d332676c02db518b9e37efe3ec74f0 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:52:45 +0530 Subject: [PATCH 09/30] Test CLI-generated receiver types from two dependencies end to end --- crates/smoketests/modules/Cargo.lock | 7 + crates/smoketests/modules/Cargo.toml | 1 + .../modules/idc-receiver/Cargo.toml | 11 ++ .../modules/idc-receiver/src/lib.rs | 58 ++++++++ .../idc-two-dependencies-types/Cargo.toml | 11 ++ .../idc-two-dependencies-types/src/lib.rs | 40 ++++++ .../src/remote_bindings/mod.rs | 1 + crates/smoketests/src/lib.rs | 15 +- .../tests/standalone/cli/generate.rs | 134 +++++++++++++++++- 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 crates/smoketests/modules/idc-receiver/Cargo.toml create mode 100644 crates/smoketests/modules/idc-receiver/src/lib.rs create mode 100644 crates/smoketests/modules/idc-two-dependencies-types/Cargo.toml create mode 100644 crates/smoketests/modules/idc-two-dependencies-types/src/lib.rs create mode 100644 crates/smoketests/modules/idc-two-dependencies-types/src/remote_bindings/mod.rs 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-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/src/lib.rs b/crates/smoketests/src/lib.rs index 423d4524e35..feaa1e6bf2b 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -189,6 +189,17 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } +/// Copies a Rust module fixture's manifest and source into a temporary project. +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}"))?; + } + 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 +213,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 diff --git a/crates/smoketests/tests/standalone/cli/generate.rs b/crates/smoketests/tests/standalone/cli/generate.rs index 39d3c5a95a8..4794fb58403 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,135 @@ 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) + ); +} From 58537432a320c32881493f9cb11fa7a2fe7a6e5a Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 08:16:02 +0530 Subject: [PATCH 10/30] address comments --- crates/cli/src/subcommands/generate.rs | 21 ++++- crates/schema/src/auto_migrate.rs | 13 ++- crates/schema/src/def/validate/v10.rs | 108 ++++++++++++++++++++++++- crates/schema/src/error.rs | 7 ++ 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index e0bc5c973d8..c0d517a6456 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -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")? diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 1f449147df3..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([ @@ -1425,7 +1425,16 @@ mod tests { 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::unit()); + 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", diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 740e2b87428..8f899cdc5ed 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1294,6 +1294,28 @@ fn attach_outboxes_to_tables( 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 { @@ -2603,7 +2625,7 @@ mod tests { fn outbox_with_on_result() -> RawModuleDefV10Builder { let mut builder = RawModuleDefV10Builder::new(); - builder + let row_type = builder .build_table_with_new_type( "PingOutbox", ProductType::from([ @@ -2616,7 +2638,16 @@ mod tests { .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::unit()); + 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", @@ -2643,6 +2674,79 @@ mod tests { ); } + #[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] { diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 56bb696ea3f..2236d630f4e 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -159,6 +159,13 @@ pub enum ValidationError { 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" )] From 4e7dd6ec0558ce84ea646621f6f1190ac9a4e679 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 08:33:10 +0530 Subject: [PATCH 11/30] update test --- crates/engine/src/update.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index 8b26299d724..85b9bc77f65 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -1101,10 +1101,19 @@ mod test { } else { table }; - table.finish(); + let row_type = table.finish(); if let Some(on_result) = on_result { - builder.add_reducer(on_result, ProductType::unit()); + builder.add_reducer( + on_result, + ProductType::from([ + ("row", row_type.into()), + ( + "result", + AlgebraicType::result(AlgebraicType::unit(), AlgebraicType::String), + ), + ]), + ); } builder.add_outbox( "outbound_pings", From 5870f22efd2d8a3cd6ad2d2cd2a2aee74d9b9531 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 08:42:26 +0530 Subject: [PATCH 12/30] comments --- crates/bindings/src/rt.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 71b70bf65ad..65e06030913 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -192,7 +192,10 @@ pub trait RemoteReducer { /// Hash of the reducer signature as seen by the generated receiver bindings. const SIGNATURE_HASH: &'static str; - /// Reducer argument tuple, excluding the reducer context. + /// 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; } From 4dc0fa6c2e3d13ff549ff4ef1a3fc9c7314d8aa9 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 20:17:01 +0530 Subject: [PATCH 13/30] use qualified types --- crates/codegen/src/rust.rs | 66 +++++++---- .../codegen/tests/outbox_builtin_shadowing.rs | 109 ++++++++++++++++++ ...codegen__codegen_rust_module_bindings.snap | 18 +-- 3 files changed, 160 insertions(+), 33 deletions(-) create mode 100644 crates/codegen/tests/outbox_builtin_shadowing.rs diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 03b2192b955..253875cdc7c 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1017,11 +1017,26 @@ fn write_type_in_context( 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::"), + 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::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")?, @@ -1029,37 +1044,40 @@ fn write_type_in_context( AlgebraicTypeUse::Uuid => write!(out, "{sdk_path}::Uuid")?, AlgebraicTypeUse::ScheduleAt => write!(out, "{sdk_path}::ScheduleAt")?, AlgebraicTypeUse::Option(inner_ty) => { - write!(out, "Option::<")?; + 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_builtin(out, "::core::result", "Result")?; + write!(out, "::<")?; write_type_in_context(module, out, ok_ty, context)?; write!(out, ", ")?; 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::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!(out, "f32")?, - PrimitiveType::F64 => write!(out, "f64")?, + 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_builtin(out, "::std::vec", "Vec")?; + write!(out, "::<")?; write_type_in_context(module, out, elem_ty, context)?; write!(out, ">")?; } @@ -2659,11 +2677,11 @@ mod tests { ( TypeContext::OutboxMacro, [ - "Option::>>", - "Result::", - "Option::>", - "Result::", - "Result::", + "::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>", ], ), ]; 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 index 591333eaa18..e0fade369e2 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap @@ -86,7 +86,7 @@ macro_rules! __spacetimedb_typecheck_outbox_add_name { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_add_name_reject_duplicate!($module; $($rest)*); $index }}; @@ -128,7 +128,7 @@ macro_rules! __spacetimedb_typecheck_outbox_add_age { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: u8 = x; }; + let _ = |x: $ty| { let _: ::core::primitive::u8 = x; }; $module::__spacetimedb_typecheck_outbox_add_age_reject_duplicate!($module; $($rest)*); $index }}; @@ -201,7 +201,7 @@ macro_rules! __spacetimedb_typecheck_outbox_add_player_name { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_add_player_name_reject_duplicate!($module; $($rest)*); $index }}; @@ -273,7 +273,7 @@ macro_rules! __spacetimedb_typecheck_outbox_add_private_name { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_add_private_name_reject_duplicate!($module; $($rest)*); $index }}; @@ -374,7 +374,7 @@ macro_rules! __spacetimedb_typecheck_outbox_delete_player_id { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as id), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: u64 = x; }; + let _ = |x: $ty| { let _: ::core::primitive::u64 = x; }; $module::__spacetimedb_typecheck_outbox_delete_player_id_reject_duplicate!($module; $($rest)*); $index }}; @@ -446,7 +446,7 @@ macro_rules! __spacetimedb_typecheck_outbox_delete_players_by_name_name { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as name), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_delete_players_by_name_name_reject_duplicate!($module; $($rest)*); $index }}; @@ -519,7 +519,7 @@ macro_rules! __spacetimedb_typecheck_outbox_expect_environment_key { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as key), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_expect_environment_key_reject_duplicate!($module; $($rest)*); $index }}; @@ -561,7 +561,7 @@ macro_rules! __spacetimedb_typecheck_outbox_expect_environment_expected { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as expected), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: Option:: = x; }; + let _ = |x: $ty| { let _: ::core::option::Option::<::std::string::String> = x; }; $module::__spacetimedb_typecheck_outbox_expect_environment_expected_reject_duplicate!($module; $($rest)*); $index }}; @@ -634,7 +634,7 @@ macro_rules! __spacetimedb_typecheck_outbox_list_over_age_age { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as age), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: u8 = x; }; + let _ = |x: $ty| { let _: ::core::primitive::u8 = x; }; $module::__spacetimedb_typecheck_outbox_list_over_age_age_reject_duplicate!($module; $($rest)*); $index }}; From bd2f88c56c80dda3e57af771bfd702da5fbf393d Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 01:45:35 +0530 Subject: [PATCH 14/30] fix arg type collisions --- crates/codegen/src/rust.rs | 38 +++++++---- .../codegen/tests/outbox_builtin_shadowing.rs | 65 ++++++++++++++++++- ...codegen__codegen_rust_module_bindings.snap | 18 ++--- ...shadowing__receiver_type_declarations.snap | 42 ++++++++++++ 4 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 crates/codegen/tests/snapshots/outbox_builtin_shadowing__receiver_type_declarations.snap diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index 253875cdc7c..e5f0cc3076f 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1016,14 +1016,14 @@ fn write_type_in_context( ) -> 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::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. + // Receiver-defined types can shadow builtins inside the bindings, and macros + // resolve types in the caller's scope. Preserve the established client spelling. let qualify_builtins = match context { - TypeContext::Client | TypeContext::Receiver => false, - TypeContext::OutboxMacro => true, + TypeContext::Client => false, + TypeContext::Receiver | TypeContext::OutboxMacro => true, }; let write_builtin = |out: &mut W, path: &str, name: &str| -> fmt::Result { if qualify_builtins { @@ -1382,8 +1382,19 @@ fn generate_module_bindings_root_file(module: &ModuleDef, type_refs: &BTreeSet>(); + used_type_names.insert("Identity".to_owned()); + used_type_names.extend(iter_reducers(module, CodegenVisibility::OnlyPublic).map(reducer_function_name)); for reducer in iter_reducers(module, CodegenVisibility::OnlyPublic) { - print_module_binding_reducer_handle(module, out, reducer); + let mut args_type = function_args_type_name(&reducer.accessor_name); + // Keep existing argument type names unless they collide with an imported + // receiver type or another item emitted into the bindings root. + while !used_type_names.insert(args_type.clone()) { + args_type.push('_'); + } + print_module_binding_reducer_handle(module, out, reducer, &args_type); out.newline(); } @@ -1472,15 +1483,14 @@ impl<'de> spacetimedb::Deserialize<'de> for Identity {{ ); } -fn print_module_binding_reducer_handle(module: &ModuleDef, out: &mut Indenter, reducer: &ReducerDef) { +fn print_module_binding_reducer_handle(module: &ModuleDef, out: &mut Indenter, reducer: &ReducerDef, args_type: &str) { 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, + args_type, &reducer.params_for_generate.elements, "pub", TypeContext::Receiver, @@ -2667,11 +2677,11 @@ mod tests { ( TypeContext::Receiver, [ - "Option::>>", - "Result::", - "Option::>", - "Result::", - "Result::", + "::core::option::Option::<::std::vec::Vec::<::core::result::Result::>>", + "::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>", ], ), ( diff --git a/crates/codegen/tests/outbox_builtin_shadowing.rs b/crates/codegen/tests/outbox_builtin_shadowing.rs index 64c1df72490..d47bdc8cac4 100644 --- a/crates/codegen/tests/outbox_builtin_shadowing.rs +++ b/crates/codegen/tests/outbox_builtin_shadowing.rs @@ -2,11 +2,54 @@ 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::collections::BTreeMap; use std::process::Command; +// Keep the snapshot focused on public types and reducer argument associations. +// The generated macro bodies are exercised by compiling the complete bindings. +fn type_declarations(code: &str) -> String { + let mut in_item = false; + code.lines() + .filter(|line| { + if line.starts_with("pub use ") + || line.starts_with("pub struct ") + || line.starts_with("impl spacetimedb::rt::RemoteReducer for ") + { + in_item = line.ends_with('{'); + true + } else if in_item { + if line.trim() == "}" { + in_item = false; + } + true + } else { + false + } + }) + .collect::>() + .join("\n") +} + #[test] -fn outbox_macros_resolve_builtins_independently_of_the_callers_scope() { +fn receiver_bindings_and_outbox_macros_preserve_builtin_and_custom_types() { let mut builder = RawModuleDefV10Builder::new(); + // Receiver-defined names can collide with both builtins and the synthetic + // argument product generated for `receive`. + let custom_string = builder.add_algebraic_type( + [], + "String", + AlgebraicType::product([("value", AlgebraicType::U64)]), + false, + ); + let custom_receive_args = builder.add_algebraic_type( + [], + "ReceiveArgs", + AlgebraicType::product([ + ("builtin", AlgebraicType::String), + ("custom", AlgebraicType::Ref(custom_string)), + ]), + false, + ); builder.add_reducer( "receive", ProductType::from([ @@ -20,13 +63,22 @@ fn outbox_macros_resolve_builtins_independently_of_the_callers_scope() { ("number", AlgebraicType::U64), ("timestamp", AlgebraicType::timestamp()), ("identity", AlgebraicType::identity()), + ("custom_string", AlgebraicType::Ref(custom_string)), + ("custom_args", AlgebraicType::Ref(custom_receive_args)), ]), ); + builder.add_reducer("noop", ProductType::unit()); let module: ModuleDef = builder.finish().try_into().unwrap(); + let files = generate_receiver_bindings(&module, ReceiverLanguage::Rust, None).unwrap(); + let declarations = files + .iter() + .map(|file| (file.filename.as_str(), type_declarations(&file.code))) + .collect::>(); + insta::assert_toml_snapshot!("receiver_type_declarations", declarations); 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() { + for file in files { let name = if file.filename == "lib.rs" { "mod.rs" } else { @@ -84,7 +136,16 @@ mod caller {{ (4, number: ::core::primitive::u64 as number), (5, timestamp: ::spacetimedb::Timestamp as timestamp), (6, identity: crate::receiver::Identity as identity), + (7, custom_string: crate::receiver::String as custom_string), + (8, custom_args: crate::receiver::ReceiveArgs as custom_args), }}); + let _ = |args: ::Args| {{ + let _: ::std::string::String = args.text; + let _: crate::receiver::String = args.custom_string; + let custom_args: crate::receiver::ReceiveArgs = args.custom_args; + let _: ::std::string::String = custom_args.builtin; + let _: crate::receiver::String = custom_args.custom; + }}; }} }} "# diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap index e0fade369e2..3f750f5d0c7 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap @@ -52,8 +52,8 @@ impl<'de> spacetimedb::Deserialize<'de> for Identity { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct AddArgs { - pub name: String, - pub age: u8, + pub name: ::std::string::String, + pub age: ::core::primitive::u8, } @@ -168,7 +168,7 @@ impl spacetimedb::rt::RemoteReducer for add { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct AddPlayerArgs { - pub name: String, + pub name: ::std::string::String, } @@ -240,7 +240,7 @@ impl spacetimedb::rt::RemoteReducer for add_player { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct AddPrivateArgs { - pub name: String, + pub name: ::std::string::String, } @@ -341,7 +341,7 @@ impl spacetimedb::rt::RemoteReducer for assert_caller_identity_is_module_identit #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct DeletePlayerArgs { - pub id: u64, + pub id: ::core::primitive::u64, } @@ -413,7 +413,7 @@ impl spacetimedb::rt::RemoteReducer for delete_player { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct DeletePlayersByNameArgs { - pub name: String, + pub name: ::std::string::String, } @@ -485,8 +485,8 @@ impl spacetimedb::rt::RemoteReducer for delete_players_by_name { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct ExpectEnvironmentArgs { - pub key: String, - pub expected: Option::, + pub key: ::std::string::String, + pub expected: ::core::option::Option::<::std::string::String>, } @@ -601,7 +601,7 @@ impl spacetimedb::rt::RemoteReducer for expect_environment { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct ListOverAgeArgs { - pub age: u8, + pub age: ::core::primitive::u8, } diff --git a/crates/codegen/tests/snapshots/outbox_builtin_shadowing__receiver_type_declarations.snap b/crates/codegen/tests/snapshots/outbox_builtin_shadowing__receiver_type_declarations.snap new file mode 100644 index 00000000000..91c7ff42d30 --- /dev/null +++ b/crates/codegen/tests/snapshots/outbox_builtin_shadowing__receiver_type_declarations.snap @@ -0,0 +1,42 @@ +--- +source: crates/codegen/tests/outbox_builtin_shadowing.rs +expression: declarations +--- +"lib.rs" = ''' +pub use receive_args_type::ReceiveArgs; +pub use string_type::String; +pub struct Identity(pub spacetimedb::Identity); +pub struct ReceiveArgs_ { + pub text: ::std::string::String, + pub values: ::std::vec::Vec::<::core::primitive::u64>, + pub maybe: ::core::option::Option::<::std::string::String>, + pub outcome: ::core::result::Result::<(), ::std::string::String>, + pub number: ::core::primitive::u64, + pub timestamp: ::spacetimedb::Timestamp, + pub identity: Identity, + pub custom_string: String, + pub custom_args: ReceiveArgs, +} +pub struct receive; +impl spacetimedb::rt::RemoteReducer for receive { + const NAME: &'static str = "receive"; + const SIGNATURE_HASH: &'static str = "c0cb30752579ca4f11c7255a77df5f9294ed3ecdf3df710a4c41d086fb9b5ebc"; + type Args = ReceiveArgs_; +} +pub struct NoopArgs { + } +pub struct noop; +impl spacetimedb::rt::RemoteReducer for noop { + const NAME: &'static str = "noop"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = NoopArgs; +}''' +"receive_args_type.rs" = ''' +pub struct ReceiveArgs { + pub builtin: ::std::string::String, + pub custom: String, +}''' +"string_type.rs" = ''' +pub struct String { + pub value: ::core::primitive::u64, +}''' From b15b8b916247b4146ed1b677fb8e48d5092107ee Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:46:03 +0530 Subject: [PATCH 15/30] Add Rust outbox declarations and runtime registration --- crates/bindings-macro/src/lib.rs | 9 +- crates/bindings-macro/src/reducer.rs | 22 +++ crates/bindings-macro/src/table.rs | 153 +++++++++++++++++- crates/bindings/src/rt.rs | 47 ++++++ crates/bindings/src/table.rs | 9 ++ .../tests/fixtures/idc_receiver/lib.rs | 122 ++++++++++++++ .../bindings/tests/ui/outbox_table_shape.rs | 53 ++++++ .../tests/ui/outbox_table_shape.stderr | 55 +++++++ 8 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 crates/bindings/tests/fixtures/idc_receiver/lib.rs create mode 100644 crates/bindings/tests/ui/outbox_table_shape.rs create mode 100644 crates/bindings/tests/ui/outbox_table_shape.stderr 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..159e5acbfaf 100644 --- a/crates/bindings-macro/src/reducer.rs +++ b/crates/bindings-macro/src/reducer.rs @@ -10,6 +10,7 @@ use syn::{FnArg, Ident, ItemFn, LitStr, PatType}; pub(crate) struct ReducerArgs { name: Option, lifecycle: Option, + on_result: Option, } enum LifecycleReducer { @@ -51,6 +52,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,6 +108,19 @@ 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)?; @@ -156,6 +176,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,6 +191,7 @@ 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; } diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index e1ff4edec8a..5e12f0916e3 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,6 +797,7 @@ enum ColumnAttr { Unique(Span), AutoInc(Span), PrimaryKey(Span), + Target(Span), Index(IndexArg), Default(syn::Expr, Span), } @@ -790,6 +819,9 @@ 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::default { Some(parse_default_attr(attr, ident)?) } else { @@ -869,6 +901,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,6 +910,7 @@ 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; for attr in field.original_attrs { let Some(attr) = ColumnAttr::parse(attr, field_ident)? else { @@ -895,6 +929,10 @@ 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)?; @@ -917,6 +955,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: field_ident.unraw().to_string(), default_value, }; @@ -930,6 +969,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 +1149,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 +1180,106 @@ 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 mut receiver_identity = receiver_module.clone(); + receiver_identity.segments.push(parse_quote!(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::RemoteReducer>::NAME, + arg_columns: #outbox_arg_columns, + target_column: #target_col, + signature_hash: <#receiver_reducer as spacetimedb::rt::RemoteReducer>::SIGNATURE_HASH, + }); + } + } + _ => quote! {}, + }; + let unique_err = if !unique_columns.is_empty() { quote!(spacetimedb::UniqueConstraintViolation) } else { @@ -1145,7 +1292,6 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R }; let field_types = fields.iter().map(|f| f.ty).collect::>(); - let tabletype_impl = quote! { use spacetimedb::Serialize; impl spacetimedb::Table for #tablehandle_ident { @@ -1166,8 +1312,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 +1485,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/src/rt.rs b/crates/bindings/src/rt.rs index 65e06030913..3d7fe9df91b 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -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; @@ -518,6 +521,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, @@ -596,6 +613,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 {} } @@ -607,6 +627,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; @@ -825,6 +852,18 @@ pub fn register_table() { table.finish(); + if let Some(outbox) = T::OUTBOX { + 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(outbox.signature_hash) + .expect("generated outbox signature hash must be valid hex"), + ); + } + module.inner.add_explicit_names(T::explicit_names()); }) } @@ -852,6 +891,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()); @@ -1133,6 +1175,7 @@ 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. @@ -1192,6 +1235,10 @@ extern "C" fn __describe_module__(description: BytesSink) { describer(&mut module) } + for (outbox_table, reducer) in module.on_result_reducers { + module.inner.set_outbox_on_result(outbox_table, reducer); + } + // Serialize the module to bsatn. let module_def = module.inner.finish(); let module_def = RawModuleDef::V10(module_def); diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index aaa678b40db..e10adfe60a9 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -478,6 +478,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 +505,14 @@ 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, + pub signature_hash: &'a str, +} + pub struct ScheduleDesc<'a> { pub reducer_or_procedure_name: &'a str, pub scheduled_at_column: u16, 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..b04f5fe39ca --- /dev/null +++ b/crates/bindings/tests/fixtures/idc_receiver/lib.rs @@ -0,0 +1,122 @@ +// 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 { + 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 { + const NAME: &'static str = "noop"; + const SIGNATURE_HASH: &'static str = "bc436888fd947d2bfd49ed6bddad31f0245c34fd42c85f9360fa0b477aa957fc"; + type Args = NoopArgs; +} 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` From 667b502934a89e76dc8c725380724db70b9a4f44 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:46:43 +0530 Subject: [PATCH 16/30] Map outbox columns to receiver parameters with param attributes --- crates/bindings-macro/src/table.rs | 24 +++- .../bindings/tests/pass/outbox_arg_columns.rs | 103 ++++++++++++++++++ .../tests/ui/outbox_receiver_typecheck.rs | 56 ++++++++++ .../tests/ui/outbox_receiver_typecheck.stderr | 77 +++++++++++++ crates/codegen/tests/codegen.rs | 36 ++++++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 crates/bindings/tests/pass/outbox_arg_columns.rs create mode 100644 crates/bindings/tests/ui/outbox_receiver_typecheck.rs create mode 100644 crates/bindings/tests/ui/outbox_receiver_typecheck.stderr diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index 5e12f0916e3..50bdfe41173 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -800,6 +800,7 @@ enum ColumnAttr { Target(Span), Index(IndexArg), Default(syn::Expr, Span), + ParamName(Ident), } impl ColumnAttr { @@ -822,6 +823,8 @@ impl ColumnAttr { } 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 { @@ -830,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())); @@ -912,6 +929,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R 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; @@ -938,6 +956,10 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R check_duplicate(&default_value, span)?; default_value = Some(expr); } + ColumnAttr::ParamName(name) => { + check_duplicate(¶m_name, name.span())?; + param_name = Some(name); + } } } @@ -955,7 +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: field_ident.unraw().to_string(), + param_name: param_name.as_ref().unwrap_or(field_ident).unraw().to_string(), default_value, }; 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/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/codegen/tests/codegen.rs b/crates/codegen/tests/codegen.rs index a1f7a5c8d08..4acea1e0f11 100644 --- a/crates/codegen/tests/codegen.rs +++ b/crates/codegen/tests/codegen.rs @@ -159,6 +159,42 @@ fn test_codegen_rust_module_bindings_include_nested_reducer_types() { 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(); From 82a5bc2399058632ebb1e7649bd268ec1b8708e1 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:46:57 +0530 Subject: [PATCH 17/30] Restrict mutation APIs on IDC outbox tables --- crates/bindings-macro/src/table.rs | 6 + crates/bindings/src/table.rs | 38 +++- crates/bindings/tests/ui/outbox_mutation.rs | 42 +++++ .../bindings/tests/ui/outbox_mutation.stderr | 175 ++++++++++++++++++ 4 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 crates/bindings/tests/ui/outbox_mutation.rs create mode 100644 crates/bindings/tests/ui/outbox_mutation.stderr diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index 50bdfe41173..0ec8a13ad25 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -1314,7 +1314,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; diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index e10adfe60a9..f1cd009730e 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") } @@ -716,7 +728,10 @@ impl> UniqueColumn) -> bool { + pub fn delete(&self, col_val: impl Borrow) -> bool + where + Tbl: MutableTable, + { self._delete(col_val.borrow()).0 } @@ -756,6 +771,7 @@ impl> UniqueColumn Tbl::Row where + Tbl: MutableTable, Col: PrimaryKey, { if Tbl::IS_EVENT { @@ -780,7 +796,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); @@ -798,7 +817,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}")) } } @@ -1045,6 +1067,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 { @@ -1383,6 +1406,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/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) From 7751fe9f6cd297722cbf59bf14fb260030ea11d0 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 13:48:23 +0530 Subject: [PATCH 18/30] Accept receiver dependencies in publish configuration --- crates/cli/src/subcommands/publish.rs | 1 + 1 file changed, 1 insertion(+) 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 Date: Sat, 24 Oct 2026 13:49:55 +0530 Subject: [PATCH 19/30] Test generated Rust IDC bindings and remote delivery end to end --- .../smoketests/modules/idc-sender/Cargo.toml | 11 + .../smoketests/modules/idc-sender/src/lib.rs | 134 ++++++ crates/smoketests/src/lib.rs | 24 +- crates/smoketests/tests/cluster.rs | 1 + crates/smoketests/tests/cluster/idc.rs | 424 ++++++++++++++++++ .../tests/standalone/cli/generate.rs | 67 +++ 6 files changed, 658 insertions(+), 3 deletions(-) create mode 100644 crates/smoketests/modules/idc-sender/Cargo.toml create mode 100644 crates/smoketests/modules/idc-sender/src/lib.rs create mode 100644 crates/smoketests/tests/cluster/idc.rs 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/src/lib.rs b/crates/smoketests/src/lib.rs index feaa1e6bf2b..5d2f56cc61f 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -466,7 +466,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)) @@ -1552,7 +1553,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) @@ -1562,7 +1571,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) @@ -1574,6 +1591,7 @@ log = "0.4" let mut cmd_args = vec![ "call", + "--no-config", "--anonymous", "--server", &self.server_url, 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/idc.rs b/crates/smoketests/tests/cluster/idc.rs new file mode 100644 index 00000000000..636590c3be6 --- /dev/null +++ b/crates/smoketests/tests/cluster/idc.rs @@ -0,0 +1,424 @@ +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 delivered = self.rows( + &self.receiver, + &format!("SELECT details FROM detailed_ping_log WHERE body = '{body}'"), + ); + 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"); + 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"); +} diff --git a/crates/smoketests/tests/standalone/cli/generate.rs b/crates/smoketests/tests/standalone/cli/generate.rs index 4794fb58403..21c3f820dce 100644 --- a/crates/smoketests/tests/standalone/cli/generate.rs +++ b/crates/smoketests/tests/standalone/cli/generate.rs @@ -222,3 +222,70 @@ fn cli_generate_two_scoped_receiver_types_compile_in_sender() { 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) + ); +} From c7dac6699cc4d7d071bd056e1b3b1b6720c8069c Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 24 Oct 2026 14:03:31 +0530 Subject: [PATCH 20/30] Restore two-dependency IDC delivery smoketest --- .../modules/idc-two-dependencies/Cargo.toml | 11 ++ .../modules/idc-two-dependencies/src/lib.rs | 50 ++++++++ crates/smoketests/tests/cluster/idc.rs | 109 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 crates/smoketests/modules/idc-two-dependencies/Cargo.toml create mode 100644 crates/smoketests/modules/idc-two-dependencies/src/lib.rs 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/tests/cluster/idc.rs b/crates/smoketests/tests/cluster/idc.rs index 636590c3be6..e3a113fba41 100644 --- a/crates/smoketests/tests/cluster/idc.rs +++ b/crates/smoketests/tests/cluster/idc.rs @@ -422,3 +422,112 @@ fn idc_outbox_uses_regenerated_bindings_after_argument_reordering() { suite.send_detailed_ping("after-update"); } + +#[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)); + } +} From f2d4aa9dd2b7f6eb39c3944422b80861ddba2b56 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 26 Oct 2026 01:35:26 +0530 Subject: [PATCH 21/30] lint --- crates/smoketests/modules/idc-sender/src/remote_bindings/mod.rs | 1 + .../modules/idc-two-dependencies/src/remote_bindings/mod.rs | 1 + 2 files changed, 2 insertions(+) create mode 100644 crates/smoketests/modules/idc-sender/src/remote_bindings/mod.rs create mode 100644 crates/smoketests/modules/idc-two-dependencies/src/remote_bindings/mod.rs 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/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 @@ + From 77b10990c874c0fae7a966c6fa05cd9a35caef53 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 26 Oct 2026 15:15:12 +0530 Subject: [PATCH 22/30] Give copied smoke test fixtures unique Cargo package names --- crates/smoketests/src/lib.rs | 37 +++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 5d2f56cc61f..2b9285dcab7 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -189,7 +189,7 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } -/// Copies a Rust module fixture's manifest and source into a temporary project. +/// 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"))?; @@ -197,6 +197,14 @@ pub fn copy_module_fixture(name: &str, dest: &Path) -> Result<()> { 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(()) } @@ -1974,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 "; From 1204a3a78ab5c3ad5e76f012bd3f9df553e4943a Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 26 Oct 2026 15:52:33 +0530 Subject: [PATCH 23/30] Avoid stale receiver reads when polling detailed IDC delivery --- crates/smoketests/tests/cluster/idc.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/smoketests/tests/cluster/idc.rs b/crates/smoketests/tests/cluster/idc.rs index e3a113fba41..0c1472fdf9c 100644 --- a/crates/smoketests/tests/cluster/idc.rs +++ b/crates/smoketests/tests/cluster/idc.rs @@ -193,15 +193,15 @@ impl IdcTest { [0, "optional-note"] ]); loop { - let delivered = self.rows( - &self.receiver, - &format!("SELECT details FROM detailed_ping_log WHERE body = '{body}'"), - ); 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])]); From 044c00a699337957535c3edc0d56a6dcb871acfc Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 27 Oct 2026 23:46:25 +0530 Subject: [PATCH 24/30] ui ficture update --- crates/bindings/tests/fixtures/idc_receiver/lib.rs | 2 +- crates/bindings/tests/ui/outbox_receiver_typecheck.stderr | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/bindings/tests/fixtures/idc_receiver/lib.rs b/crates/bindings/tests/fixtures/idc_receiver/lib.rs index b04f5fe39ca..69f2c6a2561 100644 --- a/crates/bindings/tests/fixtures/idc_receiver/lib.rs +++ b/crates/bindings/tests/fixtures/idc_receiver/lib.rs @@ -63,7 +63,7 @@ macro_rules! __spacetimedb_typecheck_outbox_receive_ping_body { 0u16 }}; ($module:ident; ($index:literal, $field:ident : $ty:ty as body), $($rest:tt)*) => {{ - let _ = |x: $ty| { let _: String = x; }; + let _ = |x: $ty| { let _: ::std::string::String = x; }; $module::__spacetimedb_typecheck_outbox_receive_ping_body_reject_duplicate!($module; $($rest)*); $index }}; diff --git a/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr index 7d0e4512f20..e3a62c5e913 100644 --- a/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr +++ b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr @@ -60,8 +60,8 @@ error[E0308]: mismatched types error[E0308]: mismatched types --> tests/ui/../fixtures/idc_receiver/lib.rs | - | let _ = |x: $ty| { let _: String = x; }; - | ------ ^ expected `String`, found `u64` + | let _ = |x: $ty| { let _: ::std::string::String = x; }; + | --------------------- ^ expected `String`, found `u64` | | | expected due to this | @@ -73,5 +73,5 @@ error[E0308]: mismatched types = 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(); }; - | ++++++++++++ +66 | let _ = |x: $ty| { let _: ::std::string::String = x.to_string(); }; + | ++++++++++++ From a268a570d8d1ef4c83c2ca58d2fcaba1cb9d274f Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 16:20:59 +0530 Subject: [PATCH 25/30] make outbox table insert-only without backward compatible --- crates/bindings-macro/src/table.rs | 111 ++++++++--- crates/bindings/src/rt.rs | 15 +- crates/bindings/src/table.rs | 78 +++++--- .../bindings/tests/pass/outbox_arg_columns.rs | 43 ++++- .../bindings/tests/ui/outbox_mutation.stderr | 174 ++++-------------- .../tests/ui/outbox_table_shape.stderr | 6 +- .../modules/idc-two-dependencies/src/lib.rs | 1 - 7 files changed, 223 insertions(+), 205 deletions(-) diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index 0ec8a13ad25..4e865b06bad 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -590,15 +590,22 @@ impl ValidatedIndex<'_> { row_type_ident: &Ident, tbl_type_ident: &Ident, flavor: AccessorType, + backend: TokenStream, ) -> TokenStream { if self.is_unique { - self.unique_accessor(row_type_ident, tbl_type_ident, flavor) + self.unique_accessor(row_type_ident, tbl_type_ident, flavor, backend) } else { - self.non_unique_accessor(vis, row_type_ident, tbl_type_ident, flavor) + self.non_unique_accessor(vis, row_type_ident, tbl_type_ident, flavor, backend) } } - fn unique_accessor(&self, row_type_ident: &Ident, tbl_type_ident: &Ident, flavor: AccessorType) -> TokenStream { + fn unique_accessor( + &self, + row_type_ident: &Ident, + tbl_type_ident: &Ident, + flavor: AccessorType, + backend: TokenStream, + ) -> TokenStream { let col = self.kind.one_col().unwrap(); let index_ident = self.accessor_name; let vis = col.vis; @@ -616,7 +623,7 @@ impl ValidatedIndex<'_> { quote! { #[doc = #doc] #vis fn #column_ident(&self) -> #unique_ty<#tbl_token, #col_ty, __indices::#index_ident> { - #unique_ty::__new(self.__backend.clone()) + #unique_ty::__new(#backend) } } } @@ -627,6 +634,7 @@ impl ValidatedIndex<'_> { row_type_ident: &Ident, tbl_type_ident: &Ident, flavor: AccessorType, + backend: TokenStream, ) -> TokenStream { let index_ident = self.accessor_name; let cols = self.kind.columns(); @@ -657,7 +665,7 @@ impl ValidatedIndex<'_> { quote! { #[doc = #doc] #vis fn #index_ident(&self) -> #handle_ty<#tbl_token, (#(#col_tys,)*), __indices::#index_ident> { - #handle_ty::__new(self.__backend.clone()) + #handle_ty::__new(#backend) } } } @@ -1045,14 +1053,41 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R let tablehandle_ident = format_ident!("{}__TableHandle", table_ident); let viewhandle_ident = format_ident!("{}__ViewHandle", table_ident); + let outboxhandle_ident = format_ident!("{}__OutboxHandle", table_ident); + let public_handle_ident = if args.outbox.is_some() { + &outboxhandle_ident + } else { + &tablehandle_ident + }; let index_descs = indices.iter().map(|index| index.desc()); - let index_accessors_rw = indices - .iter() - .map(|index| index.accessor(vis, original_struct_ident, &tablehandle_ident, AccessorType::ReadWrite)); - let index_accessors_ro = indices - .iter() - .map(|index| index.accessor(vis, original_struct_ident, &tablehandle_ident, AccessorType::Read)); + let index_accessors_rw = indices.iter().map(|index| { + index.accessor( + vis, + original_struct_ident, + &tablehandle_ident, + AccessorType::ReadWrite, + quote!(self.__backend.clone()), + ) + }); + let index_accessors_ro = indices.iter().map(|index| { + index.accessor( + vis, + original_struct_ident, + &tablehandle_ident, + AccessorType::Read, + quote!(self.__backend.clone()), + ) + }); + let outbox_index_accessors = indices.iter().map(|index| { + index.accessor( + vis, + original_struct_ident, + &tablehandle_ident, + AccessorType::Read, + quote!(self.__table.__backend().clone()), + ) + }); let index_marker_types: Vec<_> = indices .iter() .map(|index| index.marker_type(vis, &tablehandle_ident, primary_key_column.as_ref())) @@ -1314,13 +1349,7 @@ 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; @@ -1363,19 +1392,30 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R } }; + let table_handle_value = quote! { + #tablehandle_ident { __backend: self.__table_backend() } + }; + let public_handle_value = if args.outbox.is_some() { + quote! { + #outboxhandle_ident { + __table: spacetimedb::table::OutboxTable::__new(#table_handle_value), + } + } + } else { + table_handle_value + }; + // Output all macro data let trait_def = quote_spanned! {table_ident.span()=> #[allow(non_camel_case_types, dead_code)] #vis trait #table_ident { #[allow(non_camel_case_types, dead_code)] - fn #table_ident(&self) -> #tablehandle_ident; + fn #table_ident(&self) -> #public_handle_ident; } impl #table_ident for spacetimedb::Local { #[allow(non_camel_case_types, dead_code)] - fn #table_ident(&self) -> #tablehandle_ident { - #tablehandle_ident { - __backend: self.__table_backend(), - } + fn #table_ident(&self) -> #public_handle_ident { + #public_handle_value } } }; @@ -1484,6 +1524,31 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R } }; + // Keep the existing Table API intact. Outboxes expose a separate facade whose + // deref target shares insertion/iteration, but never exposes the mutable handle. + let outboxhandle_def = args.outbox.is_some().then(|| { + quote! { + #[allow(non_camel_case_types)] + #[non_exhaustive] + #vis struct #outboxhandle_ident { + __table: spacetimedb::table::OutboxTable<#tablehandle_ident>, + } + } + }); + let outboxhandle_impl = args.outbox.is_some().then(|| { + quote! { + impl #outboxhandle_ident { + #(#outbox_index_accessors)* + } + impl ::core::ops::Deref for #outboxhandle_ident { + type Target = spacetimedb::table::OutboxTable<#tablehandle_ident>; + fn deref(&self) -> &Self::Target { + &self.__table + } + } + } + }); + let viewhandle_def = quote! { #[allow(non_camel_case_types)] #[non_exhaustive] @@ -1521,11 +1586,13 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R #trait_def_view #tablehandle_def + #outboxhandle_def #viewhandle_def #table_query_handle_def #struct_level_query_impl const _: () = { + #outboxhandle_impl impl #tablehandle_ident { #(#index_accessors_rw)* } diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 3d7fe9df91b..fe319aa606d 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -522,15 +522,20 @@ where } /// 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 +pub const fn on_result_outbox(_table: fn(&crate::Local) -> T) -> &'static str +where + T: core::ops::Deref>, + Tbl: crate::Table, +{ + Tbl::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) +pub fn on_result_typecheck(_f: F, _table: fn(&crate::Local) -> T) where - T: crate::Table, - F: Fn(&ReducerContext, T::Row, Result<(), String>) -> Ret, + T: core::ops::Deref>, + Tbl: crate::Table, + F: Fn(&ReducerContext, Tbl::Row, Result<(), String>) -> Ret, Ret: IntoReducerResult, { } diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index f1cd009730e..8d7640c139b 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -318,15 +318,9 @@ 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. /// Provides common table operations, regardless of what unique constraints and indexes -/// are present. Deleting rows requires [`MutableTable`]. +/// are present. /// /// To get a `TableHandle` // TODO: should we rename this `TableHandle`? Documenting this, I think that's much clearer. @@ -443,10 +437,7 @@ 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 - where - Self: MutableTable, - { + fn delete(&self, row: Self::Row) -> bool { // 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, @@ -463,10 +454,7 @@ 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 - where - Self: MutableTable, - { + fn clear(&self) -> u64 { sys::datastore_clear(Self::table_id()).expect("datastore_clear() call failed") } @@ -475,6 +463,48 @@ pub trait Table: TableInternal + ExplicitNames { fn integrate_generated_columns(row: &mut Self::Row, generated_cols: &[u8]); } +/// An insert-only table handle used by outboxes. +/// +/// Shares ordinary table insertion and iteration without exposing deletion or updates. +/// Outbox index accessors likewise return read-only index handles. +pub struct OutboxTable { + table: T, +} + +impl OutboxTable { + #[doc(hidden)] + pub fn __new(table: T) -> Self { + Self { table } + } + + #[doc(hidden)] + pub fn __backend(&self) -> &TableHandleBackend { + self.table.__backend() + } + + /// Returns the number of pending rows. + pub fn count(&self) -> u64 { + self.table.count() + } + + /// Iterates over pending rows. + pub fn iter(&self) -> impl Iterator { + self.table.iter() + } + + /// Inserts a row, returning it with generated column values. + #[track_caller] + pub fn insert(&self, row: T::Row) -> T::Row { + self.table.insert(row) + } + + /// Inserts a row, returning any constraint violation. + #[track_caller] + pub fn try_insert(&self, row: T::Row) -> Result> { + self.table.try_insert(row) + } +} + #[doc(hidden)] #[inline] pub fn count() -> u64 { @@ -728,10 +758,7 @@ impl> UniqueColumn) -> bool - where - Tbl: MutableTable, - { + pub fn delete(&self, col_val: impl Borrow) -> bool { self._delete(col_val.borrow()).0 } @@ -771,7 +798,6 @@ impl> UniqueColumn Tbl::Row where - Tbl: MutableTable, Col: PrimaryKey, { if Tbl::IS_EVENT { @@ -796,10 +822,7 @@ impl> UniqueColumn Result> - where - Tbl: MutableTable, - { + pub fn try_insert_or_update(&self, new_row: Tbl::Row) -> Result> { 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); @@ -817,10 +840,7 @@ impl> UniqueColumn Tbl::Row - where - Tbl: MutableTable, - { + pub fn insert_or_update(&self, new_row: Tbl::Row) -> Tbl::Row { self.try_insert_or_update(new_row).unwrap_or_else(|e| panic!("{e}")) } } @@ -1067,7 +1087,6 @@ 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 { @@ -1406,7 +1425,6 @@ 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/pass/outbox_arg_columns.rs b/crates/bindings/tests/pass/outbox_arg_columns.rs index 8a7261144f2..215dc7eeb5f 100644 --- a/crates/bindings/tests/pass/outbox_arg_columns.rs +++ b/crates/bindings/tests/pass/outbox_arg_columns.rs @@ -5,7 +5,13 @@ mod remote_bindings { pub(crate) use crate::receiver; } -#[spacetimedb::table(accessor = outbound_ping, name = "WireOutbox", outbox(crate::remote_bindings::receiver::receive_ping))] +#[spacetimedb::table( + accessor = outbound_ping, + name = "WireOutbox", + outbox(crate::remote_bindings::receiver::receive_ping), + index(accessor = by_audit, btree(columns = [audit])), + index(accessor = by_audit_hash, hash(columns = [audit])), +)] pub struct OutboundPing { #[primary_key] #[auto_inc] @@ -66,15 +72,48 @@ fn main() { // 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.by_audit().filter("audit"); + let _ = table.by_audit_hash().filter("audit"); let _ = table.try_insert(row); let _ = table.count(); let _ = table.iter(); } +// Existing Table bounds, associated-type paths, and UFCS calls remain sufficient. +fn mutate_generic(table: &T, row: ::Row) { + let row = spacetimedb::Table::insert(table, row); + table.delete(row); + table.clear(); +} + +fn mutate_generic_index(column: &spacetimedb::UniqueColumn, row: T::Row) +where + T: spacetimedb::Table, + C: spacetimedb::table::Index + spacetimedb::table::Column + spacetimedb::table::PrimaryKey, +{ + let row = column.update(row); + #[cfg(feature = "unstable")] + let row = column.insert_or_update(row); + #[cfg(feature = "unstable")] + let row = column.try_insert_or_update(row).unwrap(); + column.delete(C::get_field(&row)); +} + +fn mutate_generic_indexes( + ranged: &spacetimedb::RangedIndex, + pointed: &spacetimedb::PointIndex, +) where + T: spacetimedb::Table, + R: spacetimedb::table::IndexIsRanged, + P: spacetimedb::table::IndexIsPointed, +{ + ranged.delete("value"); + pointed.delete("value"); +} + #[spacetimedb::table( accessor = ordinary, index(accessor = by_value, btree(columns = [value])), diff --git a/crates/bindings/tests/ui/outbox_mutation.stderr b/crates/bindings/tests/ui/outbox_mutation.stderr index 366702daf7e..3c0c3dce702 100644 --- a/crates/bindings/tests/ui/outbox_mutation.stderr +++ b/crates/bindings/tests/ui/outbox_mutation.stderr @@ -1,175 +1,65 @@ -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `clear` found for struct `outbound_ping__OutboxHandle` in the current scope --> 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) + | |____________________________- method `clear` not found for this struct +... +23 | table.clear(); + | ^^^^^ method not found in `outbound_ping__OutboxHandle` + | + = help: items from traits can only be used if the trait is implemented and in scope + = note: the following traits define an item `clear`, perhaps you need to implement one of them: + candidate #1: `bitflags::traits::Flags` + candidate #2: `protobuf::message::Message` + candidate #3: `spacetimedb::Table` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `delete` found for struct `UniqueColumnReadOnly` in the current scope --> 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) + | ^^^^^^ method not found in `UniqueColumnReadOnly` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `delete` found for struct `RangedIndexReadOnly` in the current scope --> 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) + | ^^^^^^ method not found in `RangedIndexReadOnly` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `delete` found for struct `PointIndexReadOnly` in the current scope --> 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) + | ^^^^^^ method not found in `PointIndexReadOnly` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `update` found for struct `UniqueColumnReadOnly` in the current scope --> 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) + | ^^^^^^ method not found in `UniqueColumnReadOnly` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `delete` found for struct `outbound_ping__OutboxHandle` in the current scope --> 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) + | |____________________________- method `delete` not found for this struct +... +31 | ctx.db.outbound_ping().delete(row); + | ^^^^^^ method not found in `outbound_ping__OutboxHandle` + | + = help: items from traits can only be used if the trait is implemented and in scope + = note: the following trait defines an item `delete`, perhaps you need to implement it: + candidate #1: `spacetimedb::Table` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `insert_or_update` found for struct `UniqueColumnReadOnly` in the current scope --> 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) + | ^^^^^^^^^^^^^^^^ method not found in `UniqueColumnReadOnly` -error[E0277]: the trait bound `outbound_ping__TableHandle: spacetimedb::table::MutableTable` is not satisfied +error[E0599]: no method named `try_insert_or_update` found for struct `UniqueColumnReadOnly` in the current scope --> 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) + | ^^^^^^^^^^^^^^^^^^^^ method not found in `UniqueColumnReadOnly` diff --git a/crates/bindings/tests/ui/outbox_table_shape.stderr b/crates/bindings/tests/ui/outbox_table_shape.stderr index 5e89e8a6be4..7792aee57a1 100644 --- a/crates/bindings/tests/ui/outbox_table_shape.stderr +++ b/crates/bindings/tests/ui/outbox_table_shape.stderr @@ -48,8 +48,8 @@ error[E0593]: function is expected to take 3 arguments, but it 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) + | 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` + | F: Fn(&ReducerContext, Tbl::Row, Result<(), String>) -> Ret, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `on_result_typecheck` diff --git a/crates/smoketests/modules/idc-two-dependencies/src/lib.rs b/crates/smoketests/modules/idc-two-dependencies/src/lib.rs index 36a9c01379c..87e7ff91206 100644 --- a/crates/smoketests/modules/idc-two-dependencies/src/lib.rs +++ b/crates/smoketests/modules/idc-two-dependencies/src/lib.rs @@ -1,7 +1,6 @@ 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 { From 64a3a9618da868ff0ef6ccb4afc1bb735c27c438 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 17:04:26 +0530 Subject: [PATCH 26/30] Regenerate Rust outbox fixture with qualified receiver builtins --- crates/bindings/tests/fixtures/idc_receiver/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bindings/tests/fixtures/idc_receiver/lib.rs b/crates/bindings/tests/fixtures/idc_receiver/lib.rs index 69f2c6a2561..d357f8bb73e 100644 --- a/crates/bindings/tests/fixtures/idc_receiver/lib.rs +++ b/crates/bindings/tests/fixtures/idc_receiver/lib.rs @@ -31,7 +31,7 @@ impl<'de> spacetimedb::Deserialize<'de> for Identity { #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct ReceivePingArgs { - pub body: String, + pub body: ::std::string::String, } #[doc(hidden)] From 7db0c465aaffcb5e0aa0d7a13d7ea837eb23f3c1 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 21:00:59 +0530 Subject: [PATCH 27/30] Apply batched suggestions from code review Co-authored-by: joshua-spacetime Signed-off-by: Shubham Mishra --- crates/codegen/src/rust.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index e5f0cc3076f..b81e3fa281f 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -1467,7 +1467,7 @@ impl spacetimedb::Serialize for Identity {{ fn serialize( &self, serializer: S, - ) -> Result {{ + ) -> ::core::result::Result {{ ::serialize(&self.0, serializer) }} }} @@ -1475,7 +1475,7 @@ impl spacetimedb::Serialize for Identity {{ impl<'de> spacetimedb::Deserialize<'de> for Identity {{ fn deserialize>( deserializer: D, - ) -> Result {{ + ) -> ::core::result::Result {{ ::deserialize(deserializer).map(Self) }} }} From 99500881e8262a6bd6785822f07ecbdbdf3e71c2 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 21:59:19 +0530 Subject: [PATCH 28/30] Regenerate Rust module bindings snapshot with qualified Result --- .../snapshots/codegen__codegen_rust_module_bindings.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap index 3f750f5d0c7..dad386570e4 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust_module_bindings.snap @@ -35,7 +35,7 @@ impl spacetimedb::Serialize for Identity { fn serialize( &self, serializer: S, - ) -> Result { + ) -> ::core::result::Result { ::serialize(&self.0, serializer) } } @@ -43,7 +43,7 @@ impl spacetimedb::Serialize for Identity { impl<'de> spacetimedb::Deserialize<'de> for Identity { fn deserialize>( deserializer: D, - ) -> Result { + ) -> ::core::result::Result { ::deserialize(deserializer).map(Self) } } From b5d3b83d84b9bde38a746f886a977582208fb31f Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 28 Oct 2026 23:06:04 +0530 Subject: [PATCH 29/30] Resolve outbox callbacks in native module test registrations --- crates/bindings/src/rt.rs | 21 ++++++++------- crates/bindings/tests/test_utils.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index fe319aa606d..59d60c70d5f 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -1210,11 +1210,20 @@ static ANONYMOUS_VIEWS: OnceLock> = OnceLock::new(); #[cfg(feature = "test-utils")] #[doc(hidden)] pub fn module_def_for_tests() -> RawModuleDef { + RawModuleDef::V10(describe_module().inner.finish()) +} + +fn describe_module() -> ModuleBuilder { let mut module = ModuleBuilder::default(); for describer in &mut *DESCRIBERS.lock().unwrap() { describer(&mut module) } - RawModuleDef::V10(module.inner.finish()) + + // Tables and reducers can register in either order, so link callbacks last. + for (outbox_table, reducer) in module.on_result_reducers.drain(..) { + module.inner.set_outbox_on_result(outbox_table, reducer); + } + module } /// Called by the host when the module is initialized @@ -1234,15 +1243,7 @@ pub fn module_def_for_tests() -> RawModuleDef { /// After initialization, the module cannot alter the schema. #[unsafe(no_mangle)] extern "C" fn __describe_module__(description: BytesSink) { - // Collect the `module`. - let mut module = ModuleBuilder::default(); - for describer in &mut *DESCRIBERS.lock().unwrap() { - describer(&mut module) - } - - for (outbox_table, reducer) in module.on_result_reducers { - module.inner.set_outbox_on_result(outbox_table, reducer); - } + let module = describe_module(); // Serialize the module to bsatn. let module_def = module.inner.finish(); diff --git a/crates/bindings/tests/test_utils.rs b/crates/bindings/tests/test_utils.rs index 33a6edfd237..c8ac1848cb7 100644 --- a/crates/bindings/tests/test_utils.rs +++ b/crates/bindings/tests/test_utils.rs @@ -2,6 +2,9 @@ use spacetimedb::spacetimedb_lib::RawModuleDef; use spacetimedb::test_utils::{TestAuth, TestQueryError}; use spacetimedb::{reducer, table, AnonymousViewContext, Query, ReducerContext, Table, Timestamp, ViewContext}; +#[path = "fixtures/idc_receiver/lib.rs"] +mod receiver; + #[table(accessor = test_utils_user, public)] #[derive(Debug, PartialEq, Eq)] pub struct TestUtilsUser { @@ -24,6 +27,21 @@ pub fn add_test_utils_user(_ctx: &ReducerContext, id: u64, name: String) { let _ = (id, name); } +#[table(accessor = test_utils_outbox, outbox(receiver::receive_ping))] +pub struct TestUtilsOutbox { + #[primary_key] + #[auto_inc] + id: u64, + audit: String, + #[param(name = body)] + payload: String, + #[target] + destination: receiver::Identity, +} + +#[reducer(on_result(test_utils_outbox))] +pub fn on_test_utils_outbox_result(_ctx: &ReducerContext, _row: TestUtilsOutbox, _result: Result<(), String>) {} + fn query_test_utils_users_by_name(from: spacetimedb::QueryBuilder, name: &str) -> impl Query { from.test_utils_user() .r#where(|user| user.name.eq(name.to_owned())) @@ -45,6 +63,7 @@ fn module_def_includes_native_test_registrations() { let mut table_names = spacetimedb::test_utils::all_table_names(); table_names.sort_unstable(); assert!(table_names.contains(&"test_utils_user")); + assert!(table_names.contains(&"test_utils_outbox")); let RawModuleDef::V10(module) = spacetimedb::test_utils::module_def() else { panic!("test utils should return a v10 raw module def"); @@ -57,11 +76,33 @@ fn module_def_includes_native_test_registrations() { assert!(tables .iter() .any(|table| table.source_name.as_ref() == "test_utils_event")); + assert!(tables + .iter() + .any(|table| table.source_name.as_ref() == "test_utils_outbox")); let reducers = module.reducers().expect("reducers section should be present"); assert!(reducers .iter() .any(|reducer| reducer.source_name.as_ref() == "add_test_utils_user")); + assert!(reducers + .iter() + .any(|reducer| reducer.source_name.as_ref() == "on_test_utils_outbox_result")); + + let outboxes = module.outboxes().expect("outboxes section should be present"); + assert_eq!(outboxes.len(), 1); + let outbox = &outboxes[0]; + assert_eq!(outbox.table_name.as_ref(), "test_utils_outbox"); + assert_eq!(outbox.remote_reducer.as_ref(), "wire_ping"); + assert_eq!(outbox.target_column, 3.into()); + assert_eq!(outbox.arg_columns, vec![2.into()]); + assert_eq!( + outbox.signature_hash, + spacetimedb::spacetimedb_lib::Hash::from_hex( + ::SIGNATURE_HASH + ) + .unwrap() + ); + assert_eq!(outbox.on_result_reducer.as_deref(), Some("on_test_utils_outbox_result")); } #[test] From 541d1d6714181b984188f52cacebbe5819eb7d83 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Thu, 29 Oct 2026 00:01:09 +0530 Subject: [PATCH 30/30] ui motion --- crates/bindings/tests/ui/outbox_receiver_typecheck.stderr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr index e3a62c5e913..5bc3baf3a49 100644 --- a/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr +++ b/crates/bindings/tests/ui/outbox_receiver_typecheck.stderr @@ -73,5 +73,5 @@ error[E0308]: mismatched types = 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 _: ::std::string::String = x.to_string(); }; +71 | let _ = |x: $ty| { let _: ::std::string::String = x.to_string(); }; | ++++++++++++