From 8b57c258946b511d2623183c7673aa0f05caecc0 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 11 May 2026 10:47:58 +0200 Subject: [PATCH 1/7] Add recursive mounts for namespaces/submodules --- crates/lib/src/db/raw_def/v10.rs | 11 +++++ crates/sats/src/typespace.rs | 10 +++++ crates/schema/src/def.rs | 18 ++++++++ crates/schema/src/def/validate/v10.rs | 60 ++++++++++++++++++++++++++- crates/schema/src/def/validate/v9.rs | 1 + 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index a801ea286be..66ff13d41b0 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -89,6 +89,9 @@ pub enum RawModuleDefV10Section { /// Names provided explicitly by the user that do not follow from the case conversion policy. ExplicitNames(ExplicitNames), + + /// Mounted submodules, keyed by the namespace they are mounted under. + Mounts(Vec<(String, RawModuleDefV10)>), } #[derive(Debug, Clone, Copy, Default, SpacetimeType)] @@ -510,6 +513,14 @@ pub struct RawViewDefV10 { } impl RawModuleDefV10 { + /// Get the mounted submodules for this module definition. + pub fn mounts(&self) -> Option<&Vec<(String, RawModuleDefV10)>> { + self.sections.iter().find_map(|s| match s { + RawModuleDefV10Section::Mounts(mounts) => Some(mounts), + _ => None, + }) + } + /// Get the types section, if present. pub fn types(&self) -> Option<&Vec> { self.sections.iter().find_map(|s| match s { diff --git a/crates/sats/src/typespace.rs b/crates/sats/src/typespace.rs index 6daf91460ea..63d8c902644 100644 --- a/crates/sats/src/typespace.rs +++ b/crates/sats/src/typespace.rs @@ -413,6 +413,16 @@ impl_st!([T] Vec, ts => <[T]>::make_type(ts)); impl_st!([T, const N: usize] SmallVec<[T; N]>, ts => <[T]>::make_type(ts)); impl_st!([T] Option, ts => AlgebraicType::option(T::make_type(ts))); +impl SpacetimeType for (T, U) +where + T: SpacetimeType, + U: SpacetimeType, +{ + fn make_type(ts: &mut S) -> AlgebraicType { + AlgebraicType::product([T::make_type(ts), U::make_type(ts)]) + } +} + impl_st!([] spacetimedb_primitives::ArgId, AlgebraicType::U64); impl_st!([] spacetimedb_primitives::ColId, AlgebraicType::U16); impl_st!([] spacetimedb_primitives::TableId, AlgebraicType::U32); diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 89c201e3f85..0bf90170f73 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -151,6 +151,9 @@ pub struct ModuleDef { /// was authored under. #[allow(unused)] raw_module_def_version: RawModuleDefVersion, + + /// Mounted submodules, keyed by the namespace they are mounted under. + mounts: Vec<(String, ModuleDef)>, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -167,6 +170,11 @@ impl ModuleDef { self.raw_module_def_version } + /// The mounted submodules of the module definition. + pub fn mounts(&self) -> &[(String, ModuleDef)] { + &self.mounts + } + /// The tables of the module definition. pub fn tables(&self) -> impl Iterator { self.tables.values() @@ -437,6 +445,7 @@ impl From for RawModuleDefV9 { row_level_security_raw, procedures, raw_module_def_version: _, + mounts: _, } = val; // Extract column defaults from tables before consuming tables @@ -493,6 +502,7 @@ impl From for RawModuleDefV10 { row_level_security_raw, procedures, raw_module_def_version: _, + mounts, } = val; let mut sections = Vec::new(); @@ -605,6 +615,14 @@ impl From for RawModuleDefV10 { // Always emit ExplicitNames so canonical names survive the round-trip. sections.push(RawModuleDefV10Section::ExplicitNames(explicit_names)); + let mounts: Vec<_> = mounts + .into_iter() + .map(|(namespace, module)| (namespace, module.into())) + .collect(); + if !mounts.is_empty() { + sections.push(RawModuleDefV10Section::Mounts(mounts)); + } + RawModuleDefV10 { sections } } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 7ebbaae06d4..6df76b8609c 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -81,6 +81,12 @@ pub fn validate(def: RawModuleDefV10) -> Result { .cloned() .map(ExplicitNamesLookup::new) .unwrap_or_default(); + let mounts = def + .mounts() + .into_iter() + .flat_map(|mounts| mounts.iter().cloned()) + .map(validate_mount) + .collect_all_errors::>(); // Original `typespace` needs to be preserved to be assign `accesor_name`s to columns. let typespace_with_accessor_names = typespace.clone(); @@ -263,8 +269,12 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views) = - (tables_types_reducers_procedures_views).map_err(|errors| errors.sort_deduplicate())?; + let (tables, types, reducers, procedures, views, mounts) = (tables_types_reducers_procedures_views, mounts) + .combine_errors() + .map(|((tables, types, reducers, procedures, views), mounts)| { + (tables, types, reducers, procedures, views, mounts) + }) + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; let typespace_for_generate = typespace_for_generate.finish(); @@ -281,9 +291,17 @@ pub fn validate(def: RawModuleDefV10) -> Result { lifecycle_reducers, procedures, raw_module_def_version: RawModuleDefVersion::V10, + mounts, }) } +fn validate_mount((namespace, module): (String, RawModuleDefV10)) -> Result<(String, ModuleDef)> { + Identifier::new(namespace.clone().into()) + .map_err(|error| ValidationErrors::from(ValidationError::IdentifierError { error }))?; + + Ok((namespace, validate(module)?)) +} + /// Change the visibility of scheduled functions and lifecycle reducers to Internal. /// fn change_scheduled_functions_and_lifetimes_visibility( @@ -1256,6 +1274,44 @@ mod tests { }); } + #[test] + fn validates_mounted_submodules_recursively() { + let mut mounted_builder = RawModuleDefV10Builder::new(); + mounted_builder + .build_table_with_new_type("Sessions", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![( + "authlib".to_string(), + mounted_builder.finish(), + )])], + }; + + let def: ModuleDef = raw.try_into().expect("mounted module should validate"); + let mounts = def.mounts(); + + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].0, "authlib"); + assert!(mounts[0].1.table(&expect_identifier("sessions")).is_some()); + } + + #[test] + fn invalid_mount_namespace() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![( + "".to_string(), + RawModuleDefV10::default(), + )])], + }; + + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::IdentifierError { error } => { + error == &IdentifierError::Empty {} + }); + } + #[test] fn invalid_unique_constraint_column_ref() { let mut builder = RawModuleDefV10Builder::new(); diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index d040435afe5..5daf738a4c0 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -166,6 +166,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { lifecycle_reducers, procedures, raw_module_def_version: RawModuleDefVersion::V9OrEarlier, + mounts: Vec::new(), }) } From 4e43bea6f04da2bc4e04531abf5f33016aa51f33 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 11 May 2026 10:52:20 +0200 Subject: [PATCH 2/7] Comment --- crates/sats/src/typespace.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/sats/src/typespace.rs b/crates/sats/src/typespace.rs index 63d8c902644..f6d19a472c2 100644 --- a/crates/sats/src/typespace.rs +++ b/crates/sats/src/typespace.rs @@ -413,6 +413,8 @@ impl_st!([T] Vec, ts => <[T]>::make_type(ts)); impl_st!([T, const N: usize] SmallVec<[T; N]>, ts => <[T]>::make_type(ts)); impl_st!([T] Option, ts => AlgebraicType::option(T::make_type(ts))); +// SATS derives need tuples to have a structural product representation so +// tuple payloads like `(String, RawModuleDefV10)` can appear in wire types. impl SpacetimeType for (T, U) where T: SpacetimeType, From 3139601ecd52d4bcc1eb9a39babf8c19806cee36 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 11 May 2026 14:57:07 +0200 Subject: [PATCH 3/7] Fix tests --- crates/bindings/tests/ui/reducers.stderr | 2 +- crates/bindings/tests/ui/tables.stderr | 4 ++-- crates/bindings/tests/ui/views.stderr | 4 ++-- crates/schema/src/def/validate/v10.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/bindings/tests/ui/reducers.stderr b/crates/bindings/tests/ui/reducers.stderr index acc25c83008..cb72f72fc21 100644 --- a/crates/bindings/tests/ui/reducers.stderr +++ b/crates/bindings/tests/ui/reducers.stderr @@ -55,12 +55,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () + (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box - ColumnAttribute and $N others = note: required for `Test` to implement `ReducerArg` diff --git a/crates/bindings/tests/ui/tables.stderr b/crates/bindings/tests/ui/tables.stderr index 7609d9ba378..43c0635884f 100644 --- a/crates/bindings/tests/ui/tables.stderr +++ b/crates/bindings/tests/ui/tables.stderr @@ -25,12 +25,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () + (T, U) AlgebraicType AlgebraicTypeRef Alpha Arc ArrayType - Box and $N others error[E0277]: the trait bound `Test: Deserialize<'de>` is not satisfied @@ -190,12 +190,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () + (T, U) AlgebraicType AlgebraicTypeRef Alpha Arc ArrayType - Box and $N others = note: required for `Test` to implement `TableColumn` diff --git a/crates/bindings/tests/ui/views.stderr b/crates/bindings/tests/ui/views.stderr index 1baf379ceea..138248bb72e 100644 --- a/crates/bindings/tests/ui/views.stderr +++ b/crates/bindings/tests/ui/views.stderr @@ -308,12 +308,12 @@ help: the trait `SpacetimeType` is not implemented for `NotSpacetimeType` = help: the following other types implement trait `SpacetimeType`: &T () + (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box - ColumnAttribute and $N others = note: required for `Option` to implement `ViewReturn` @@ -377,12 +377,12 @@ help: the trait `SpacetimeType` is not implemented for `NotSpacetimeType` = help: the following other types implement trait `SpacetimeType`: &T () + (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box - ColumnAttribute and $N others = note: required for `Option` to implement `SpacetimeType` diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 6df76b8609c..418c4506d85 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -895,7 +895,7 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}; + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section}; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::ScheduleAt; From 3f198f09f8cfa2df07184be6877e5a1700afd208 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 11 May 2026 15:05:36 +0200 Subject: [PATCH 4/7] Format --- crates/schema/src/def/validate/v10.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 418c4506d85..9f9b55f7b39 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -895,7 +895,9 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section}; + use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, + }; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::ScheduleAt; From 2c869031e07e938268e1c53c28196cc79ea7bd8e Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Wed, 13 May 2026 09:56:28 +0200 Subject: [PATCH 5/7] switch raw mount entry from an anonymous tuple to a named raw struct --- .../Autogen/RawModuleDefV10Section.g.cs | 3 +- .../Internal/Autogen/RawModuleMountV10.g.cs | 36 +++++++++++++++++++ crates/lib/src/db/raw_def/v10.rs | 12 +++++-- crates/sats/src/typespace.rs | 12 ------- crates/schema/src/def.rs | 9 +++-- crates/schema/src/def/validate/v10.rs | 22 ++++++------ 6 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 1a299f93c3a..e7500844198 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -19,6 +19,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List LifeCycleReducers, System.Collections.Generic.List RowLevelSecurity, SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, - ExplicitNames ExplicitNames + ExplicitNames ExplicitNames, + System.Collections.Generic.List Mounts )>; } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs new file mode 100644 index 00000000000..0df52895d65 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class RawModuleMountV10 + { + [DataMember(Name = "namespace")] + public string Namespace; + [DataMember(Name = "module")] + public RawModuleDefV10 Module; + + public RawModuleMountV10( + string Namespace, + RawModuleDefV10 Module + ) + { + this.Namespace = Namespace; + this.Module = Module; + } + + public RawModuleMountV10() + { + this.Namespace = ""; + this.Module = new(); + } + } +} diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 66ff13d41b0..038c53d69d6 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -91,7 +91,15 @@ pub enum RawModuleDefV10Section { ExplicitNames(ExplicitNames), /// Mounted submodules, keyed by the namespace they are mounted under. - Mounts(Vec<(String, RawModuleDefV10)>), + Mounts(Vec), +} + +#[derive(Debug, Clone, SpacetimeType)] +#[sats(crate = crate)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +pub struct RawModuleMountV10 { + pub namespace: String, + pub module: RawModuleDefV10, } #[derive(Debug, Clone, Copy, Default, SpacetimeType)] @@ -514,7 +522,7 @@ pub struct RawViewDefV10 { impl RawModuleDefV10 { /// Get the mounted submodules for this module definition. - pub fn mounts(&self) -> Option<&Vec<(String, RawModuleDefV10)>> { + pub fn mounts(&self) -> Option<&Vec> { self.sections.iter().find_map(|s| match s { RawModuleDefV10Section::Mounts(mounts) => Some(mounts), _ => None, diff --git a/crates/sats/src/typespace.rs b/crates/sats/src/typespace.rs index f6d19a472c2..6daf91460ea 100644 --- a/crates/sats/src/typespace.rs +++ b/crates/sats/src/typespace.rs @@ -413,18 +413,6 @@ impl_st!([T] Vec, ts => <[T]>::make_type(ts)); impl_st!([T, const N: usize] SmallVec<[T; N]>, ts => <[T]>::make_type(ts)); impl_st!([T] Option, ts => AlgebraicType::option(T::make_type(ts))); -// SATS derives need tuples to have a structural product representation so -// tuple payloads like `(String, RawModuleDefV10)` can appear in wire types. -impl SpacetimeType for (T, U) -where - T: SpacetimeType, - U: SpacetimeType, -{ - fn make_type(ts: &mut S) -> AlgebraicType { - AlgebraicType::product([T::make_type(ts), U::make_type(ts)]) - } -} - impl_st!([] spacetimedb_primitives::ArgId, AlgebraicType::U64); impl_st!([] spacetimedb_primitives::ColId, AlgebraicType::U16); impl_st!([] spacetimedb_primitives::TableId, AlgebraicType::U32); diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 0bf90170f73..d518f2ac5ef 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -33,8 +33,8 @@ use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ ExplicitNames, RawConstraintDefV10, RawIndexDefV10, RawLifeCycleReducerDefV10, RawModuleDefV10, - RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, - RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, + RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, RawReducerDefV10, RawRowLevelSecurityDefV10, + RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, @@ -617,7 +617,10 @@ impl From for RawModuleDefV10 { let mounts: Vec<_> = mounts .into_iter() - .map(|(namespace, module)| (namespace, module.into())) + .map(|(namespace, module)| RawModuleMountV10 { + namespace, + module: module.into(), + }) .collect(); if !mounts.is_empty() { sections.push(RawModuleDefV10Section::Mounts(mounts)); diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 9f9b55f7b39..7dd6616d2e7 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -295,11 +295,11 @@ pub fn validate(def: RawModuleDefV10) -> Result { }) } -fn validate_mount((namespace, module): (String, RawModuleDefV10)) -> Result<(String, ModuleDef)> { - Identifier::new(namespace.clone().into()) +fn validate_mount(mount: RawModuleMountV10) -> Result<(String, ModuleDef)> { + Identifier::new(mount.namespace.clone().into()) .map_err(|error| ValidationErrors::from(ValidationError::IdentifierError { error }))?; - Ok((namespace, validate(module)?)) + Ok((mount.namespace, validate(mount.module)?)) } /// Change the visibility of scheduled functions and lifecycle reducers to Internal. @@ -1284,10 +1284,10 @@ mod tests { .finish(); let raw = RawModuleDefV10 { - sections: vec![RawModuleDefV10Section::Mounts(vec![( - "authlib".to_string(), - mounted_builder.finish(), - )])], + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "authlib".to_string(), + module: mounted_builder.finish(), + }])], }; let def: ModuleDef = raw.try_into().expect("mounted module should validate"); @@ -1301,10 +1301,10 @@ mod tests { #[test] fn invalid_mount_namespace() { let raw = RawModuleDefV10 { - sections: vec![RawModuleDefV10Section::Mounts(vec![( - "".to_string(), - RawModuleDefV10::default(), - )])], + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "".to_string(), + module: RawModuleDefV10::default(), + }])], }; let result: Result = raw.try_into(); From 45131565e2e75601a2d658632ff72acfaff67d41 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Wed, 13 May 2026 10:44:20 +0200 Subject: [PATCH 6/7] Fix tests --- crates/bindings/tests/ui/reducers.stderr | 2 +- crates/bindings/tests/ui/tables.stderr | 4 ++-- crates/bindings/tests/ui/views.stderr | 4 ++-- crates/schema/src/def/validate/v10.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/bindings/tests/ui/reducers.stderr b/crates/bindings/tests/ui/reducers.stderr index cb72f72fc21..acc25c83008 100644 --- a/crates/bindings/tests/ui/reducers.stderr +++ b/crates/bindings/tests/ui/reducers.stderr @@ -55,12 +55,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () - (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box + ColumnAttribute and $N others = note: required for `Test` to implement `ReducerArg` diff --git a/crates/bindings/tests/ui/tables.stderr b/crates/bindings/tests/ui/tables.stderr index 43c0635884f..7609d9ba378 100644 --- a/crates/bindings/tests/ui/tables.stderr +++ b/crates/bindings/tests/ui/tables.stderr @@ -25,12 +25,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () - (T, U) AlgebraicType AlgebraicTypeRef Alpha Arc ArrayType + Box and $N others error[E0277]: the trait bound `Test: Deserialize<'de>` is not satisfied @@ -190,12 +190,12 @@ help: the trait `SpacetimeType` is not implemented for `Test` = help: the following other types implement trait `SpacetimeType`: &T () - (T, U) AlgebraicType AlgebraicTypeRef Alpha Arc ArrayType + Box and $N others = note: required for `Test` to implement `TableColumn` diff --git a/crates/bindings/tests/ui/views.stderr b/crates/bindings/tests/ui/views.stderr index 138248bb72e..1baf379ceea 100644 --- a/crates/bindings/tests/ui/views.stderr +++ b/crates/bindings/tests/ui/views.stderr @@ -308,12 +308,12 @@ help: the trait `SpacetimeType` is not implemented for `NotSpacetimeType` = help: the following other types implement trait `SpacetimeType`: &T () - (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box + ColumnAttribute and $N others = note: required for `Option` to implement `ViewReturn` @@ -377,12 +377,12 @@ help: the trait `SpacetimeType` is not implemented for `NotSpacetimeType` = help: the following other types implement trait `SpacetimeType`: &T () - (T, U) AlgebraicType AlgebraicTypeRef Arc ArrayType Box + ColumnAttribute and $N others = note: required for `Option` to implement `SpacetimeType` diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 7dd6616d2e7..c0ff11df333 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -896,7 +896,7 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; use spacetimedb_lib::db::raw_def::v10::{ - CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, + CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, RawModuleMountV10, }; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; From c20c4155edde82cba993515e086ff3b84985b9af Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 11 May 2026 13:31:19 +0200 Subject: [PATCH 7/7] Implement module mounts for typescript --- .../src/lib/autogen/types.ts | 53 ++++++-- crates/bindings-typescript/src/lib/schema.ts | 34 ++++- .../bindings-typescript/src/server/schema.ts | 121 ++++++++++++++---- .../tests/schema_mounts.test.ts | 117 +++++++++++++++++ 4 files changed, 294 insertions(+), 31 deletions(-) create mode 100644 crates/bindings-typescript/tests/schema_mounts.test.ts diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 0ce535eca0f..4804b1d01f5 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -316,15 +316,50 @@ export const RawModuleDef = __t.enum('RawModuleDef', { }); export type RawModuleDef = __Infer; -export const RawModuleDefV10 = __t.object('RawModuleDefV10', { - get sections() { - return __t.array(RawModuleDefV10Section); - }, -}); -export type RawModuleDefV10 = __Infer; +export type RawModuleDefV10 = { + sections: RawModuleDefV10Section[]; +}; + +export const RawModuleDefV10: any = __t.lazy(() => + __t.object('RawModuleDefV10', { + get sections(): any { + return __t.array(RawModuleDefV10Section); + }, + }) +); + +export type RawModuleDefV10Mount = { + namespace: string; + module: RawModuleDefV10; +}; + +export const RawModuleDefV10Mount: any = __t.lazy(() => + __t.object('RawModuleDefV10Mount', { + get namespace(): any { + return __t.string(); + }, + get module(): any { + return RawModuleDefV10; + }, + }) +); + +export type RawModuleDefV10Section = + | { tag: 'Typespace'; value: Typespace } + | { tag: 'Types'; value: RawTypeDefV10[] } + | { tag: 'Tables'; value: RawTableDefV10[] } + | { tag: 'Reducers'; value: RawReducerDefV10[] } + | { tag: 'Procedures'; value: RawProcedureDefV10[] } + | { tag: 'Views'; value: RawViewDefV10[] } + | { tag: 'Schedules'; value: RawScheduleDefV10[] } + | { tag: 'LifeCycleReducers'; value: RawLifeCycleReducerDefV10[] } + | { tag: 'RowLevelSecurity'; value: RawRowLevelSecurityDefV9[] } + | { tag: 'CaseConversionPolicy'; value: CaseConversionPolicy } + | { tag: 'ExplicitNames'; value: ExplicitNames } + | { tag: 'Mounts'; value: RawModuleDefV10Mount[] }; // The tagged union or sum type for the algebraic type `RawModuleDefV10Section`. -export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { +export const RawModuleDefV10Section: any = __t.enum('RawModuleDefV10Section', { get Typespace() { return Typespace; }, @@ -358,8 +393,10 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get ExplicitNames() { return ExplicitNames; }, + get Mounts(): any { + return __t.array(RawModuleDefV10Mount); + }, }); -export type RawModuleDefV10Section = __Infer; export const RawModuleDefV8 = __t.object('RawModuleDefV8', { get typespace() { diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..075ea99d132 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -7,10 +7,20 @@ import { } from './algebraic_type'; import type { CaseConversionPolicy, + ExplicitNames, + RawLifeCycleReducerDefV10, + RawModuleDefV10Mount, RawModuleDefV10, RawModuleDefV10Section, + RawProcedureDefV10, + RawReducerDefV10, + RawRowLevelSecurityDefV9, + RawScheduleDefV10, RawScopedTypeNameV10, RawTableDefV10, + RawTypeDefV10, + RawViewDefV10, + Typespace, } from './autogen/types'; import type { UntypedIndex } from './indexes'; import type { UntypedTableDef } from './table'; @@ -174,7 +184,18 @@ type CompoundTypeCache = Map< >; export type ModuleDef = { - [S in RawModuleDefV10Section as Uncapitalize]: S['value']; + typespace: Typespace; + types: RawTypeDefV10[]; + tables: RawTableDefV10[]; + reducers: RawReducerDefV10[]; + procedures: RawProcedureDefV10[]; + views: RawViewDefV10[]; + schedules: RawScheduleDefV10[]; + lifeCycleReducers: RawLifeCycleReducerDefV10[]; + rowLevelSecurity: RawRowLevelSecurityDefV9[]; + caseConversionPolicy: CaseConversionPolicy; + explicitNames: ExplicitNames; + mounts: RawModuleDefV10Mount[]; }; type Section = RawModuleDefV10Section; @@ -199,6 +220,7 @@ export class ModuleContext { explicitNames: { entries: [], }, + mounts: [], }; get moduleDef(): ModuleDef { @@ -245,9 +267,19 @@ export class ModuleContext { value: module.caseConversionPolicy, } ); + push( + module.mounts && { + tag: 'Mounts', + value: module.mounts, + } + ); return { sections }; } + addMount(mount: RawModuleDefV10Mount) { + this.#moduleDef.mounts.push(mount); + } + /** * Set the case conversion policy for this module. * Called by the settings mechanism. diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index b9eb258762b..c9a71a69297 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,5 +1,6 @@ import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; import { CaseConversionPolicy, Lifecycle } from '../lib/autogen/types'; +import type { RawModuleDefV10 } from '../lib/autogen/types'; import { type ParamsAsObject, type ParamsObj, @@ -14,6 +15,7 @@ import { } from '../lib/schema'; import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; +import { hasOwn } from '../lib/util'; import { makeProcedureExport, type ProcedureExport, @@ -46,6 +48,8 @@ export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { schemaType: S; + exportsRegistered = false; + schedulesResolved = false; existingFunctions = new Set(); reducers: Reducers = []; procedures: Procedures = []; @@ -77,6 +81,10 @@ export class SchemaInner< } resolveSchedules() { + if (this.schedulesResolved) { + return; + } + this.schedulesResolved = true; for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) { const functionName = this.functionExports.get(reducer()); if (functionName === undefined) { @@ -138,22 +146,8 @@ export class Schema implements ModuleDefaultExport { } [moduleHooks](exports: object) { - // if (!(hasOwn(exports, 'default') && exports.default instanceof Schema)) { - // throw new TypeError('must export schema as default export'); - // } - const registeredSchema = this.#ctx; - for (const [name, moduleExport] of Object.entries(exports)) { - if (name === 'default') continue; - if (!isModuleExport(moduleExport)) { - throw new TypeError( - 'exporting something that is not a spacetime export' - ); - } - checkExportContext(moduleExport, registeredSchema); - moduleExport[registerExport](registeredSchema, name); - } - registeredSchema.resolveSchedules(); - return makeHooks(registeredSchema); + this.buildRawModuleDefV10(exports); + return makeHooks(this.#ctx); } get schemaType(): S { @@ -168,6 +162,18 @@ export class Schema implements ModuleDefaultExport { return this.#ctx.typespace; } + /** Internal: register exports and materialize the RawModuleDefV10 for upload. */ + buildRawModuleDefV10( + exports: object, + opts?: { ignoreNonModuleExports?: boolean } + ): RawModuleDefV10 { + registerModuleExports(this.#ctx, exports, { + ignoreNonModuleExports: opts?.ignoreNonModuleExports ?? false, + }); + this.#ctx.resolveSchedules(); + return this.#ctx.rawModuleDefV10(); + } + /** * Defines a SpacetimeDB reducer function. * @@ -543,18 +549,89 @@ export interface ModuleSettings { CASE_CONVERSION_POLICY?: CaseConversionPolicy; } -export function schema>( - tables: H, +type MountedModuleNamespace = { + default: Schema; + [key: string]: unknown; +}; + +type SchemaEntry = UntypedTableSchema | MountedModuleNamespace; + +type ExtractTableEntries> = { + [K in keyof H as H[K] extends UntypedTableSchema ? K : never]: Extract< + H[K], + UntypedTableSchema + >; +}; + +function isUntypedTableSchema(x: unknown): x is UntypedTableSchema { + return typeof x === 'object' && x !== null && hasOwn(x, 'tableDef'); +} + +function isMountedModuleNamespace(x: unknown): x is MountedModuleNamespace { + return ( + typeof x === 'object' && + x !== null && + hasOwn(x, 'default') && + x.default instanceof Schema + ); +} + +function registerModuleExports( + schema: SchemaInner, + exports: object, + opts?: { ignoreNonModuleExports?: boolean } +) { + if (schema.exportsRegistered) { + return; + } + schema.exportsRegistered = true; + + for (const [name, moduleExport] of Object.entries(exports)) { + if (name === 'default') continue; + if (!isModuleExport(moduleExport)) { + if (opts?.ignoreNonModuleExports) { + continue; + } + throw new TypeError('exporting something that is not a spacetime export'); + } + checkExportContext(moduleExport, schema); + moduleExport[registerExport](schema, name); + } +} + +export function schema>( + entries: H, moduleSettings?: ModuleSettings -): Schema> { - const ctx = new SchemaInner>(ctx => { +): Schema>> { + const ctx = new SchemaInner>>(ctx => { // Apply module settings. if (moduleSettings?.CASE_CONVERSION_POLICY != null) { ctx.setCaseConversionPolicy(moduleSettings.CASE_CONVERSION_POLICY); } const tableSchemas: Record = {}; - for (const [accName, table] of Object.entries(tables)) { + for (const [accName, entry] of Object.entries(entries)) { + if (entry instanceof Schema) { + throw new TypeError( + `schema entry '${accName}' looks like a default import; use \`import * as ${accName} from '...'\` so the mount can see the library's named reducer exports.` + ); + } + if (isMountedModuleNamespace(entry)) { + ctx.addMount({ + namespace: accName, + module: entry.default.buildRawModuleDefV10(entry, { + ignoreNonModuleExports: true, + }), + }); + continue; + } + if (!isUntypedTableSchema(entry)) { + throw new TypeError( + `schema entry '${accName}' must be a table or a mounted module namespace object` + ); + } + + const table = entry; const tableDef = table.tableDef(ctx, accName); tableSchemas[accName] = tableToSchema(accName, table, tableDef); ctx.moduleDef.tables.push(tableDef); @@ -574,7 +651,7 @@ export function schema>( }); } } - return { tables: tableSchemas } as TablesToSchema; + return { tables: tableSchemas } as TablesToSchema>; }); return new Schema(ctx); diff --git a/crates/bindings-typescript/tests/schema_mounts.test.ts b/crates/bindings-typescript/tests/schema_mounts.test.ts new file mode 100644 index 00000000000..4fe3acb41a3 --- /dev/null +++ b/crates/bindings-typescript/tests/schema_mounts.test.ts @@ -0,0 +1,117 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock( + 'spacetime:sys@2.0', + () => ({ + moduleHooks: Symbol('moduleHooks'), + }), + { virtual: true } +); + +vi.mock('spacetime:sys@2.1', () => ({}), { virtual: true }); + +vi.mock('../src/server/runtime', () => ({ + makeHooks: () => ({}), + callProcedure: () => new Uint8Array(), + callUserFunction: (fn: (...args: any[]) => any, ...args: any[]) => + fn(...args), + ReducerCtxImpl: class {}, + sys: { + row_iter_bsatn_close: () => {}, + }, +})); + +describe('schema mounts', () => { + let schema: typeof import('../src/server/schema').schema; + let table: typeof import('../src/lib/table').table; + let t: typeof import('../src/lib/type_builders').t; + + beforeAll(async () => { + ({ schema } = await import('../src/server/schema')); + ({ table } = await import('../src/lib/table')); + ({ t } = await import('../src/lib/type_builders')); + }); + + it('emits mounted submodule module defs and resolves mounted schedules', () => { + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + + const sessionCleanupTick = table( + { + name: 'session_cleanup_tick', + scheduled: (): any => cleanExpiredSessions, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } + ); + + const sessions = table( + { name: 'sessions' }, + { + id: t.u64().primaryKey().autoInc(), + } + ); + + const authSchema = schema({ + sessions, + sessionCleanupTick, + }); + + const cleanExpiredSessions = authSchema.reducer(() => {}); + const authLib = { + default: authSchema, + cleanExpiredSessions, + }; + + const consumer = schema({ + players, + myauth: authLib, + }); + + const raw = consumer.buildRawModuleDefV10({}); + const mounts = raw.sections.find( + section => section.tag === 'Mounts' + )?.value; + + expect(mounts).toHaveLength(1); + expect(mounts?.[0]?.namespace).toBe('myauth'); + + const mountedSections = mounts?.[0]?.module.sections ?? []; + const mountedReducers = mountedSections.find( + section => section.tag === 'Reducers' + )?.value; + const mountedSchedules = mountedSections.find( + section => section.tag === 'Schedules' + )?.value; + + expect(mountedReducers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceName: 'cleanExpiredSessions' }), + ]) + ); + expect(mountedSchedules).toEqual([ + expect.objectContaining({ + tableName: 'sessionCleanupTick', + functionName: 'cleanExpiredSessions', + }), + ]); + }); + + it('rejects default-import style mounts with a clear error', () => { + const sessions = table( + { name: 'sessions' }, + { + id: t.u64().primaryKey().autoInc(), + } + ); + + const authSchema = schema({ sessions }); + + expect(() => + schema({ + myauth: authSchema as any, + }) + ).toThrow(/looks like a default import/); + }); +});