From 785a4463765d2ea96082509920f58f3b76b71561 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Tue, 9 Jun 2026 17:10:40 +0200 Subject: [PATCH 1/3] Rework codegen and address concerns --- .../autogen/RawModuleDefV10Section.g.h | 20 +- .../internal/autogen/RawModuleMountV10.g.h | 32 ++ .../autogen/RawViewPrimaryKeyDefV10.g.h | 28 + .../Autogen/RawModuleDefV10Section.g.cs | 3 +- .../Internal/Autogen/RawModuleMountV10.g.cs | 36 ++ .../src/lib/autogen/types.ts | 11 + crates/bindings-typescript/src/lib/schema.ts | 41 +- crates/codegen/src/typescript.rs | 23 +- .../src/host/wasm_common/module_host_actor.rs | 2 +- crates/lib/src/db/raw_def/v10.rs | 19 + crates/schema/src/def.rs | 502 +++++++++++++++++- crates/schema/src/def/validate/v10.rs | 296 ++++++++++- crates/schema/src/def/validate/v9.rs | 1 + crates/schema/src/error.rs | 13 + crates/schema/src/table_name.rs | 20 +- 15 files changed, 998 insertions(+), 49 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawViewPrimaryKeyDefV10.g.h create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index 1efcad29ed5..a62c8702562 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -12,21 +12,23 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "RawProcedureDefV10.g.h" -#include "CaseConversionPolicy.g.h" +#include "RawTableDefV10.g.h" +#include "Typespace.g.h" #include "RawLifeCycleReducerDefV10.g.h" #include "RawReducerDefV10.g.h" -#include "RawHttpHandlerDefV10.g.h" #include "RawTypeDefV10.g.h" -#include "ExplicitNames.g.h" -#include "RawViewDefV10.g.h" -#include "RawScheduleDefV10.g.h" -#include "Typespace.g.h" -#include "RawTableDefV10.g.h" #include "RawRowLevelSecurityDefV9.g.h" #include "RawHttpRouteDefV10.g.h" +#include "RawModuleMountV10.g.h" +#include "RawViewDefV10.g.h" +#include "ExplicitNames.g.h" +#include "RawProcedureDefV10.g.h" +#include "CaseConversionPolicy.g.h" +#include "RawScheduleDefV10.g.h" +#include "RawViewPrimaryKeyDefV10.g.h" +#include "RawHttpHandlerDefV10.g.h" namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector, std::vector) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h new file mode 100644 index 00000000000..0b1ea9dc053 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +namespace SpacetimeDB::Internal { +struct RawModuleDefV10; +} // namespace SpacetimeDB::Internal + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { + std::string namespace_; + std::shared_ptr module; + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, namespace_); + ::SpacetimeDB::bsatn::serialize(writer, *module); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(namespace_, module) +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawViewPrimaryKeyDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawViewPrimaryKeyDefV10.g.h new file mode 100644 index 00000000000..e2f350d9b73 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawViewPrimaryKeyDefV10.g.h @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawViewPrimaryKeyDefV10) { + std::string view_source_name; + std::vector columns; + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, view_source_name); + ::SpacetimeDB::bsatn::serialize(writer, columns); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(view_source_name, columns) +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 124cf639b64..95fbc55dd1a 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -22,6 +22,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( ExplicitNames ExplicitNames, System.Collections.Generic.List HttpHandlers, System.Collections.Generic.List HttpRoutes, - System.Collections.Generic.List ViewPrimaryKeys + System.Collections.Generic.List ViewPrimaryKeys, + 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/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 2c2b00143ff..d89793d6a4d 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -390,6 +390,9 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get ViewPrimaryKeys() { return __t.array(RawViewPrimaryKeyDefV10); }, + get Mounts() { + return __t.array(RawModuleMountV10); + }, }); export type RawModuleDefV10Section = __Infer; @@ -431,6 +434,14 @@ export const RawModuleDefV9 = __t.object('RawModuleDefV9', { }); export type RawModuleDefV9 = __Infer; +export const RawModuleMountV10 = __t.object('RawModuleMountV10', { + namespace: __t.string(), + get module(): any { + return RawModuleDefV10; + }, +}); +export type RawModuleMountV10 = __Infer; + export const RawProcedureDefV10 = __t.object('RawProcedureDefV10', { sourceName: __t.string(), get params() { diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index 1229aed82b8..fdf2e73f57d 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -7,10 +7,23 @@ import { } from './algebraic_type'; import type { CaseConversionPolicy, + ExplicitNames, + RawHttpHandlerDefV10, + RawHttpRouteDefV10, + RawLifeCycleReducerDefV10, + RawModuleMountV10, RawModuleDefV10, RawModuleDefV10Section, + RawProcedureDefV10, + RawReducerDefV10, + RawRowLevelSecurityDefV9, + RawScheduleDefV10, RawScopedTypeNameV10, RawTableDefV10, + RawTypeDefV10, + RawViewDefV10, + Typespace, + RawViewPrimaryKeyDefV10, } from './autogen/types'; import type { UntypedIndex } from './indexes'; import type { UntypedTableDef } from './table'; @@ -42,6 +55,7 @@ export type TableNamesOf = Values< */ export type UntypedSchemaDef = { tables: Record; + namespaces?: Record; }; /** @@ -174,7 +188,21 @@ 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[]; + viewPrimaryKeys: RawViewPrimaryKeyDefV10[]; + schedules: RawScheduleDefV10[]; + lifeCycleReducers: RawLifeCycleReducerDefV10[]; + httpHandlers: RawHttpHandlerDefV10[]; + httpRoutes: RawHttpRouteDefV10[]; + rowLevelSecurity: RawRowLevelSecurityDefV9[]; + caseConversionPolicy: CaseConversionPolicy; + explicitNames: ExplicitNames; + mounts: RawModuleMountV10[]; }; type Section = RawModuleDefV10Section; @@ -202,6 +230,7 @@ export class ModuleContext { explicitNames: { entries: [], }, + mounts: [], }; get moduleDef(): ModuleDef { @@ -266,9 +295,19 @@ export class ModuleContext { value: module.caseConversionPolicy, } ); + push( + module.mounts && { + tag: 'Mounts', + value: module.mounts, + } + ); return { sections }; } + addMount(mount: RawModuleMountV10) { + this.#moduleDef.mounts.push(mount); + } + /** * Set the case conversion policy for this module. * Called by the settings mechanism. diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 28bc1fb4c91..5e45c691358 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -81,7 +81,7 @@ impl Lang for TypeScript { writeln!(out, "export default __t.row({{"); out.indent(1); - write_object_type_builder_fields(module, out, &product_def.elements, table.primary_key, true, true).unwrap(); + write_object_type_builder_fields(module, out, "", &product_def.elements, table.primary_key, true, true).unwrap(); out.dedent(1); writeln!(out, "}});"); OutputFile { @@ -139,7 +139,7 @@ impl Lang for TypeScript { writeln!(out, "export const params = {{"); out.with_indent(|out| { - write_object_type_builder_fields(module, out, &procedure.params_for_generate.elements, None, true, false) + write_object_type_builder_fields(module, out, "", &procedure.params_for_generate.elements, None, true, false) .unwrap() }); writeln!(out, "}};"); @@ -605,7 +605,7 @@ fn define_body_for_reducer(module: &ModuleDef, out: &mut Indenter, params: &[(Id writeln!(out, "}};"); } else { writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, params, None, true, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, "", params, None, true, false).unwrap()); writeln!(out, "}};"); } } @@ -630,7 +630,7 @@ fn define_body_for_product( writeln!(out, "}});"); } else { writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, name, elements, None, true, false).unwrap()); writeln!(out, "}});"); } writeln!(out, "export type {name} = __Infer;"); @@ -719,6 +719,7 @@ fn write_table_opts<'a>( fn write_object_type_builder_fields( module: &ModuleDef, out: &mut Indenter, + type_name: &str, elements: &[(Identifier, AlgebraicTypeUse)], primary_key: Option, convert_case: bool, @@ -736,7 +737,7 @@ fn write_object_type_builder_fields( None => false, }; let original_name = (write_original_name && convert_case && *name != **ident).then_some(&**ident); - write_type_builder_field(module, out, &name, original_name, ty, is_primary_key)?; + write_type_builder_field(module, out, type_name, &name, original_name, ty, is_primary_key)?; } Ok(()) @@ -755,6 +756,7 @@ fn type_contains_ref(ty: &AlgebraicTypeUse) -> bool { fn write_type_builder_field( module: &ModuleDef, out: &mut Indenter, + type_name: &str, name: &str, original_name: Option<&str>, ty: &AlgebraicTypeUse, @@ -764,7 +766,14 @@ fn write_type_builder_field( let needs_getter = type_contains_ref(ty); if needs_getter { - writeln!(out, "get {name}() {{"); + if type_name == "RawModuleMountV10" && name == "module" { + // HACK: Fixes a type inference error (TS7022/TS7023) for const types in typescript due to the recursive + // type: RawModuleDefV10 -> ModuleMountsV10 -> RawModuleDefV10 + // Annotating this getter with `: any` breaks the cycle without affecting other types. + writeln!(out, "get {name}(): any {{"); + } else { + writeln!(out, "get {name}() {{"); + } out.indent(1); write!(out, "return "); } else { @@ -873,7 +882,7 @@ fn define_body_for_sum( (Identifier::for_test(pascal), ty.clone()) }) .collect(); - out.with_indent(|out| write_object_type_builder_fields(module, out, &pascal_variants, None, false, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, name, &pascal_variants, None, false, false).unwrap()); writeln!(out, "}});"); writeln!(out, "export type {name} = __Infer;"); out.newline(); diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 4489fb9f8bd..97185f6a5db 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -1571,7 +1571,7 @@ impl AllVmMetrics { let def = &info.module_def; let reducers = def.reducer_ids_and_defs(); let num_reducers = reducers.len() as u32; - let reducers = reducers.map(|(_, def)| def.name()); + let reducers = reducers.into_iter().map(|(_, def)| def.name()); // These are the views: let views = def.views().map(|def| def.name()); diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 1ab04d7a6bd..794420540b0 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -98,6 +98,9 @@ pub enum RawModuleDefV10Section { /// Primary key metadata for views. ViewPrimaryKeys(Vec), + + /// Mounted submodules, keyed by the namespace they are mounted under. + Mounts(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -124,6 +127,14 @@ pub enum MethodOrAny { Method(crate::http::Method), } +#[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)] #[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] @@ -558,6 +569,14 @@ pub struct RawViewPrimaryKeyDefV10 { } impl RawModuleDefV10 { + /// Get the mounted submodules for this module definition. + pub fn mounts(&self) -> Option<&Vec> { + 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/schema/src/def.rs b/crates/schema/src/def.rs index 9ad07dce72f..4f64ccdd519 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -33,9 +33,9 @@ use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ ExplicitNames, MethodOrAny, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, - RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, - RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, - RawTypeDefV10, RawViewDefV10, RawViewPrimaryKeyDefV10, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, + RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, + RawTableDefV10, RawTypeDefV10, RawViewDefV10, RawViewPrimaryKeyDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, @@ -49,7 +49,9 @@ use spacetimedb_primitives::{ ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, }; use spacetimedb_sats::raw_identifier::RawIdentifier; -use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; +use spacetimedb_sats::{ + AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace, +}; pub mod deserialize; pub mod error; @@ -164,6 +166,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: IndexMap, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -180,6 +185,11 @@ impl ModuleDef { self.raw_module_def_version } + /// The mounted submodules of the module definition. + pub fn mounts(&self) -> &IndexMap { + &self.mounts + } + /// The tables of the module definition. pub fn tables(&self) -> impl Iterator { self.tables.values() @@ -205,14 +215,165 @@ impl ModuleDef { self.tables().filter_map(|table| table.schedule.as_ref()) } + /// All tables across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(namespace, owning_def, table_def)` where `namespace` is the dot-terminated + /// namespace string (e.g., `"alias."`) to be prepended to the table's name for database storage. + /// The consumer module's own tables yield namespace `""`. + pub fn all_tables_with_prefix(&self) -> Vec<(String, &ModuleDef, &TableDef)> { + let mut out = Vec::new(); + self.collect_tables_with_prefix("", &mut out); + out + } + + fn collect_tables_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a TableDef)>) { + for table in self.tables.values() { + out.push((prefix.to_string(), self, table)); + } + for (ns, mount) in &self.mounts { + mount.collect_tables_with_prefix(&format!("{prefix}{ns}."), out); + } + } + + /// All views across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(namespace, owning_def, view_def)` where `namespace` is the dot-terminated + /// namespace string (e.g., `"alias."`) to be prepended to the view's name. + /// The consumer module's own views yield namespace `""`. + pub fn all_views_with_prefix(&self) -> Vec<(String, &ModuleDef, &ViewDef)> { + let mut out = Vec::new(); + self.collect_views_with_prefix("", &mut out); + out + } + + fn collect_views_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ViewDef)>) { + for view in self.views.values() { + out.push((prefix.to_string(), self, view)); + } + for (ns, mount) in &self.mounts { + mount.collect_views_with_prefix(&format!("{prefix}{ns}."), out); + } + } + + /// Look up a table by its full namespaced name (e.g., `"lib.library_table"` or `"user"`). + pub fn find_table_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef)> { + self.all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, table_def)| format!("{}{}", prefix, &*table_def.accessor_name) == full_name) + } + + /// Look up a view by its full namespaced name (e.g., `"lib.library_view"` or `"my_view"`). + pub fn find_view_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &ViewDef)> { + self.all_views_with_prefix() + .into_iter() + .find(|(prefix, _, view_def)| format!("{}{}", prefix, &*view_def.name) == full_name) + } + + /// Look up an index by its full namespaced name (e.g., `"lib.library_table_id_idx_btree"`). + pub fn find_index_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &IndexDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for idx in table.indexes.values() { + if format!("{}{}", prefix, &*idx.name) == full_name { + return Some((prefix, owning, table, idx)); + } + } + } + None + } + + /// Look up a sequence by its full namespaced name (e.g., `"lib.library_table_id_seq"`). + pub fn find_sequence_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &SequenceDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for seq in table.sequences.values() { + if format!("{}{}", prefix, &*seq.name) == full_name { + return Some((prefix, owning, table, seq)); + } + } + } + None + } + + /// Look up a constraint by its full namespaced name (e.g., `"lib.library_table_id_unique"`). + pub fn find_constraint_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &ConstraintDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for constraint in table.constraints.values() { + if format!("{}{}", prefix, &*constraint.name) == full_name { + return Some((prefix, owning, table, constraint)); + } + } + } + None + } + /// The reducers of the module definition. pub fn reducers(&self) -> impl Iterator { self.reducers.values() } - /// Returns an iterator over all reducer ids and definitions. - pub fn reducer_ids_and_defs(&self) -> impl ExactSizeIterator { - self.reducers.values().enumerate().map(|(idx, def)| (idx.into(), def)) + /// Returns all reducer ids and definitions in depth-first mount order. + /// + /// IDs are assigned as follows: consumer's own reducers first (0..N), then each + /// mounted submodule's reducers in the order they appear in `mounts`, recursively. + pub fn reducer_ids_and_defs(&self) -> Vec<(ReducerId, &ReducerDef)> { + let mut out = Vec::with_capacity(self.reducer_count()); + self.collect_reducers(0, &mut out); + out + } + + /// Total reducer count including all mounted submodules (depth-first sum). + pub fn reducer_count(&self) -> usize { + self.reducers.len() + self.mounts.values().map(|m| m.reducer_count()).sum::() + } + + fn collect_reducers<'a>(&'a self, offset: usize, out: &mut Vec<(ReducerId, &'a ReducerDef)>) { + for (i, def) in self.reducers.values().enumerate() { + out.push(((offset + i).into(), def)); + } + let mut child_offset = offset + self.reducers.len(); + for mount in self.mounts.values() { + mount.collect_reducers(child_offset, out); + child_offset += mount.reducer_count(); + } + } + + /// All reducers across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(prefix, owning_def, reducer_def)` where `prefix` is the slash-terminated + /// namespace string (e.g., `"lib/"`) to be prepended to the reducer's name as its wire name. + /// The consumer module's own reducers yield prefix `""`. + pub fn all_reducers_with_prefix(&self) -> Vec<(String, &ModuleDef, &ReducerDef)> { + let mut out = Vec::new(); + self.collect_reducers_with_prefix("", &mut out); + out + } + + fn collect_reducers_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ReducerDef)>) { + for reducer in self.reducers.values() { + out.push((prefix.to_string(), self, reducer)); + } + for (ns, mount) in &self.mounts { + mount.collect_reducers_with_prefix(&format!("{prefix}{ns}/"), out); + } + } + + /// All procedures across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(prefix, owning_def, procedure_def)` where `prefix` is the slash-terminated + /// namespace string (e.g., `"lib/"`) to be prepended to the procedure's name as its wire name. + /// The consumer module's own procedures yield prefix `""`. + pub fn all_procedures_with_prefix(&self) -> Vec<(String, &ModuleDef, &ProcedureDef)> { + let mut out = Vec::new(); + self.collect_procedures_with_prefix("", &mut out); + out + } + + fn collect_procedures_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ProcedureDef)>) { + for procedure in self.procedures.values() { + out.push((prefix.to_string(), self, procedure)); + } + for (ns, mount) in &self.mounts { + mount.collect_procedures_with_prefix(&format!("{prefix}{ns}/"), out); + } } /// The procedures of the module definition. @@ -364,14 +525,62 @@ impl ModuleDef { self.reducers.get_full(name).map(|(idx, _, def)| (idx.into(), def)) } - /// Look up a reducer by its id. + /// Look up a reducer by its wire name, resolving qualified names like `"myauth/verify_token"`. + /// + /// A plain name searches the consumer's own reducers. A slash-qualified name routes to + /// the matching mount and recurses. Nesting is supported: `"auth/baz/cleanup"`. + /// Returns the depth-first `ReducerId` and the `ReducerDef`. + pub fn reducer_by_name(&self, name: &str) -> Option<(ReducerId, &ReducerDef)> { + self.reducer_by_name_with_module(name).map(|(id, def, _)| (id, def)) + } + + /// Like `reducer_by_name` but also returns the `ModuleDef` that owns the reducer. + /// Use the returned `ModuleDef` (not `self`) when calling `arg_seed_for`, so that + /// type-index references in the `ReducerDef` are resolved against the correct typespace. + pub fn reducer_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(ReducerId, &'a ReducerDef, &'a ModuleDef)> { + match name.split_once('.') { + None => self + .reducers + .get_full(name) + .map(|(idx, _, def)| (idx.into(), def, self)), + Some((namespace, rest)) => { + let mut offset = self.reducers.len(); + for (ns, mount) in &self.mounts { + if ns == namespace { + let (inner_id, def, owning) = mount.reducer_by_name_with_module(rest)?; + return Some(((offset + inner_id.idx()).into(), def, owning)); + } + offset += mount.reducer_count(); + } + None + } + } + } + + /// Look up a reducer by its depth-first id. pub fn reducer_by_id(&self, id: ReducerId) -> &ReducerDef { - &self.reducers[id.idx()] + self.get_reducer_by_id(id) + .unwrap_or_else(|| panic!("reducer id {id:?} out of range")) } - /// Look up a reducer by its id. + /// Look up a reducer by its depth-first id, returning `None` if it doesn't exist. pub fn get_reducer_by_id(&self, id: ReducerId) -> Option<&ReducerDef> { - self.reducers.get_index(id.idx()).map(|(_, def)| def) + let idx = id.idx(); + if idx < self.reducers.len() { + return self.reducers.get_index(idx).map(|(_, def)| def); + } + let mut offset = self.reducers.len(); + for mount in self.mounts.values() { + let count = mount.reducer_count(); + if idx < offset + count { + return mount.get_reducer_by_id(ReducerId::from(idx - offset)); + } + offset += count; + } + None } /// Look up a view by its id, and whether it is anonymous. @@ -382,6 +591,130 @@ impl ModuleDef { .map(|(_, def)| def) } + /// Look up a view by its globally-unique fn_ptr (the offset-adjusted id used by the WASM dispatch layer). + /// Returns the `ViewDef` and the owning `ModuleDef`. + pub fn get_view_by_global_id_with_module( + &self, + global_id: ViewFnPtr, + is_anonymous: bool, + ) -> Option<(&ViewDef, &ModuleDef)> { + self.get_view_by_global_id_inner(global_id.0, is_anonymous, 0, 0) + } + + fn get_view_by_global_id_inner( + &self, + global_id: u32, + is_anonymous: bool, + anon_offset: u32, + non_anon_offset: u32, + ) -> Option<(&ViewDef, &ModuleDef)> { + let local_count = if is_anonymous { + self.anon_view_count() as u32 + } else { + self.non_anon_view_count() as u32 + }; + let offset = if is_anonymous { anon_offset } else { non_anon_offset }; + if global_id < offset + local_count { + return self + .views + .values() + .find(|def| def.fn_ptr.0 + offset == global_id && def.is_anonymous == is_anonymous) + .map(|def| (def, self)); + } + let mut anon_off = anon_offset + self.anon_view_count() as u32; + let mut non_anon_off = non_anon_offset + self.non_anon_view_count() as u32; + for mount in self.mounts.values() { + let mount_anon = mount.total_anon_view_count() as u32; + let mount_non_anon = mount.total_non_anon_view_count() as u32; + let mount_count = if is_anonymous { mount_anon } else { mount_non_anon }; + let mount_off = if is_anonymous { anon_off } else { non_anon_off }; + if global_id < mount_off + mount_count { + return mount.get_view_by_global_id_inner(global_id, is_anonymous, anon_off, non_anon_off); + } + anon_off += mount_anon; + non_anon_off += mount_non_anon; + } + None + } + + /// Look up a view by its wire name, resolving dot-qualified names like `"lib.library_view"`. + /// + /// A plain name searches this module's own views. A dot-qualified name routes to + /// the matching mount and recurses. Returns the `ViewDef` and the owning `ModuleDef`. + pub fn view_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(&'a ViewDef, &'a ModuleDef)> { + match name.split_once('.') { + None => self.views.get(name).map(|def| (def, self)), + Some((namespace, rest)) => { + let mount = self.mounts.get(namespace)?; + mount.view_by_name_with_module(rest) + } + } + } + + /// Like [`view_by_name_with_module`] but also returns the globally-unique `ViewFnPtr` + /// that the WASM dispatch layer expects (offset by all anon/non-anon views that precede + /// this one in depth-first module order). + pub fn view_by_name_with_global_fn_ptr<'a>( + &'a self, + name: &str, + ) -> Option<(ViewFnPtr, &'a ViewDef, &'a ModuleDef)> { + let anon_offset = 0u32; + let non_anon_offset = 0u32; + self.view_by_name_with_global_fn_ptr_inner(name, anon_offset, non_anon_offset) + } + + fn view_by_name_with_global_fn_ptr_inner<'a>( + &'a self, + name: &str, + anon_offset: u32, + non_anon_offset: u32, + ) -> Option<(ViewFnPtr, &'a ViewDef, &'a ModuleDef)> { + match name.split_once('.') { + None => { + let def = self.views.get(name)?; + let offset = if def.is_anonymous { anon_offset } else { non_anon_offset }; + Some((ViewFnPtr(def.fn_ptr.0 + offset), def, self)) + } + Some((namespace, rest)) => { + let mut anon_off = anon_offset + self.anon_view_count() as u32; + let mut non_anon_off = non_anon_offset + self.non_anon_view_count() as u32; + for (ns, mount) in &self.mounts { + if ns == namespace { + return mount.view_by_name_with_global_fn_ptr_inner(rest, anon_off, non_anon_off); + } + anon_off += mount.total_anon_view_count() as u32; + non_anon_off += mount.total_non_anon_view_count() as u32; + } + None + } + } + } + + /// Count of anonymous views in this module (not including mounts). + pub fn anon_view_count(&self) -> usize { + self.views.values().filter(|v| v.is_anonymous).count() + } + + /// Count of non-anonymous views in this module (not including mounts). + pub fn non_anon_view_count(&self) -> usize { + self.views.values().filter(|v| !v.is_anonymous).count() + } + + /// Total anonymous view count including all mounted submodules (depth-first sum). + pub fn total_anon_view_count(&self) -> usize { + self.anon_view_count() + + self.mounts.values().map(|m| m.total_anon_view_count()).sum::() + } + + /// Total non-anonymous view count including all mounted submodules (depth-first sum). + pub fn total_non_anon_view_count(&self) -> usize { + self.non_anon_view_count() + + self.mounts.values().map(|m| m.total_non_anon_view_count()).sum::() + } + /// Convenience method to look up a procedure, possibly by a string. pub fn procedure>(&self, name: &K) -> Option<&ProcedureDef> { // If the string IS a valid identifier, we can just look it up. @@ -399,12 +732,63 @@ impl ModuleDef { /// Look up a procuedure by its id, panicking if it doesn't exist. pub fn procedure_by_id(&self, id: ProcedureId) -> &ProcedureDef { - &self.procedures[id.idx()] + self.get_procedure_by_id(id) + .unwrap_or_else(|| panic!("procedure id {id:?} out of range")) } /// Look up a procuedure by its id, returning `None` if it doesn't exist. pub fn get_procedure_by_id(&self, id: ProcedureId) -> Option<&ProcedureDef> { - self.procedures.get_index(id.idx()).map(|(_, def)| def) + let idx = id.idx(); + if idx < self.procedures.len() { + return self.procedures.get_index(idx).map(|(_, def)| def); + } + let mut offset = self.procedures.len(); + for mount in self.mounts.values() { + let count = mount.procedure_count(); + if idx < offset + count { + return mount.get_procedure_by_id(ProcedureId::from(idx - offset)); + } + offset += count; + } + None + } + + /// Total procedure count including all mounted submodules (depth-first sum). + pub fn procedure_count(&self) -> usize { + self.procedures.len() + self.mounts.values().map(|m| m.procedure_count()).sum::() + } + + /// Look up a procedure by its wire name, resolving qualified names like `"mylib/proc_name"`. + /// + /// A plain name searches the module's own procedures. A slash-qualified name routes to + /// the matching mount and recurses. Returns the depth-first `ProcedureId` and the `ProcedureDef`. + pub fn procedure_by_name(&self, name: &str) -> Option<(ProcedureId, &ProcedureDef)> { + self.procedure_by_name_with_module(name).map(|(id, def, _)| (id, def)) + } + + /// Like `procedure_by_name` but also returns the `ModuleDef` that owns the procedure. + /// Use the returned `ModuleDef` (not `self`) when calling `arg_seed_for`. + pub fn procedure_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(ProcedureId, &'a ProcedureDef, &'a ModuleDef)> { + match name.split_once('.') { + None => self + .procedures + .get_full(name) + .map(|(idx, _, def)| (idx.into(), def, self)), + Some((namespace, rest)) => { + let mut offset = self.procedures.len(); + for (ns, mount) in &self.mounts { + if ns == namespace { + let (inner_id, def, owning) = mount.procedure_by_name_with_module(rest)?; + return Some(((offset + inner_id.idx()).into(), def, owning)); + } + offset += mount.procedure_count(); + } + None + } + } } /// Looks up a lifecycle reducer defined in the module. @@ -412,6 +796,11 @@ impl ModuleDef { self.lifecycle_reducers[lifecycle].map(|i| (i, &self.reducers[i.idx()])) } + /// All lifecycle reducer assignments for this module (does not include mounted submodules). + pub fn lifecycle_reducers_map(&self) -> &EnumMap> { + &self.lifecycle_reducers + } + /// Returns a `DeserializeSeed` that can pull data from a `Deserializer` for `def`. pub fn arg_seed_for<'a, T>(&'a self, def: &'a T) -> ArgsSeed<'a, T> { ArgsSeed(self.typespace.with_type(def)) @@ -507,6 +896,7 @@ impl From for RawModuleDefV9 { http_handlers: _, http_routes: _, raw_module_def_version: _, + mounts: _, } = val; // Extract column defaults from tables before consuming tables @@ -565,6 +955,7 @@ impl From for RawModuleDefV10 { http_handlers, http_routes, raw_module_def_version: _, + mounts, } = val; let mut sections = Vec::new(); @@ -723,6 +1114,17 @@ 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)| RawModuleMountV10 { + namespace, + module: module.into(), + }) + .collect(); + if !mounts.is_empty() { + sections.push(RawModuleDefV10Section::Mounts(mounts)); + } + RawModuleDefV10 { sections } } } @@ -2174,4 +2576,78 @@ mod tests { .count() == 2)) } + + #[test] + fn mounted_reducer_ids_are_depth_first() { + use spacetimedb_lib::db::raw_def::v10::{ + RawModuleDefV10Builder, RawModuleDefV10Section, RawModuleMountV10, + }; + + // baz library: 1 reducer + let mut baz_builder = RawModuleDefV10Builder::new(); + baz_builder.add_reducer("baz_reduce", ProductType::unit()); + + // auth library: 1 own reducer, mounts baz + let mut auth_builder = RawModuleDefV10Builder::new(); + auth_builder.add_reducer("auth_verify", ProductType::unit()); + let mut auth_raw = auth_builder.finish(); + auth_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "baz".to_string(), + module: baz_builder.finish(), + }])); + + // consumer: 2 own reducers, mounts auth + let mut consumer_builder = RawModuleDefV10Builder::new(); + consumer_builder.add_reducer("consumer_a", ProductType::unit()); + consumer_builder.add_reducer("consumer_b", ProductType::unit()); + let mut consumer_raw = consumer_builder.finish(); + consumer_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth_raw, + }])); + + let def: ModuleDef = consumer_raw.try_into().expect("valid module"); + + // Total count: 2 consumer + 1 auth + 1 baz + assert_eq!(def.reducer_count(), 4); + + // Depth-first order: consumer_a=0, consumer_b=1, auth_verify=2, baz_reduce=3 + let ids_and_defs = def.reducer_ids_and_defs(); + assert_eq!(ids_and_defs.len(), 4); + assert_eq!(ids_and_defs[0].0, ReducerId(0)); + assert_eq!(&*ids_and_defs[0].1.name, "consumer_a"); + assert_eq!(ids_and_defs[1].0, ReducerId(1)); + assert_eq!(&*ids_and_defs[1].1.name, "consumer_b"); + assert_eq!(ids_and_defs[2].0, ReducerId(2)); + assert_eq!(&*ids_and_defs[2].1.name, "auth_verify"); + assert_eq!(ids_and_defs[3].0, ReducerId(3)); + assert_eq!(&*ids_and_defs[3].1.name, "baz_reduce"); + + // get_reducer_by_id resolves mounted reducer IDs correctly + assert_eq!(&*def.reducer_by_id(ReducerId(2)).name, "auth_verify"); + assert_eq!(&*def.reducer_by_id(ReducerId(3)).name, "baz_reduce"); + assert!(def.get_reducer_by_id(ReducerId(4)).is_none()); + + // reducer_by_name routes plain names to own reducers + let (id, rdef) = def.reducer_by_name("consumer_a").expect("plain name resolves"); + assert_eq!(id, ReducerId(0)); + assert_eq!(&*rdef.name, "consumer_a"); + + // reducer_by_name routes qualified names to mounted reducers + let (id, rdef) = def.reducer_by_name("auth.auth_verify").expect("qualified name resolves"); + assert_eq!(id, ReducerId(2)); + assert_eq!(&*rdef.name, "auth_verify"); + + // reducer_by_name routes deeply nested qualified names + let (id, rdef) = def + .reducer_by_name("auth.baz.baz_reduce") + .expect("nested qualified name resolves"); + assert_eq!(id, ReducerId(3)); + assert_eq!(&*rdef.name, "baz_reduce"); + + // Non-existent names return None + assert!(def.reducer_by_name("auth.nonexistent").is_none()); + assert!(def.reducer_by_name("nonexistent").is_none()); + assert!(def.reducer_by_name("nonamespace.auth_verify").is_none()); + } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index aa398743f97..310ba105035 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,10 +1,13 @@ +use enum_map::EnumMap; use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::bsatn::Deserializer; use spacetimedb_lib::db::raw_def::v10::*; +use spacetimedb_lib::db::raw_def::v9::Lifecycle; use spacetimedb_lib::db::view::{extract_view_return_product_type_ref, ViewKind}; use spacetimedb_lib::de::DeserializeSeed as _; use spacetimedb_lib::http::character_is_acceptable_for_route_path; +use spacetimedb_primitives::ReducerId; use spacetimedb_sats::{Typespace, WithTypespace}; use crate::def::validate::v9::{ @@ -71,7 +74,7 @@ impl From for ValidationCase { } } } -/// Validate a `RawModuleDefV9` and convert it into a `ModuleDef`, +/// Validate a `RawModuleDefV10` and convert it into a `ModuleDef`, /// or return a stream of errors if the definition is invalid. pub fn validate(def: RawModuleDefV10) -> Result { let mut typespace = def.typespace().cloned().unwrap_or_else(|| Typespace::EMPTY.clone()); @@ -83,6 +86,12 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(ExplicitNamesLookup::new) .unwrap_or_default(); let view_primary_keys = def.view_primary_keys().cloned().unwrap_or_default(); + let mounts = validate_mounts( + def.mounts() + .into_iter() + .flat_map(|mounts| mounts.iter().cloned()) + .collect(), + ); // Original `typespace` needs to be preserved to be assign `accesor_name`s to columns. let typespace_with_accessor_names = typespace.clone(); @@ -292,14 +301,15 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views, http_handlers, http_routes) = - tables_types_reducers_procedures_views - .map( - |(tables, types, reducers, procedures, views, (http_handlers, http_routes))| { - (tables, types, reducers, procedures, views, http_handlers, http_routes) - }, - ) - .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; + let ((tables, types, reducers, procedures, views, (http_handlers, http_routes)), mounts) = ( + tables_types_reducers_procedures_views, + mounts, + ) + .combine_errors() + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; + + validate_no_lifecycle_conflicts(&lifecycle_reducers, &mounts) + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; let typespace_for_generate = typespace_for_generate.finish(); @@ -318,9 +328,97 @@ pub fn validate(def: RawModuleDefV10) -> Result { http_handlers, http_routes, raw_module_def_version: RawModuleDefVersion::V10, + mounts, }) } +/// Validate that each mount's namespace is a valid identifier of at most 63 characters, +/// and that no two mounts share the same namespace. +/// This function will inspect each sub-mount and recursively collect errors. +fn validate_mounts(mounts: Vec) -> Result> { + let mut errors = vec![]; + let mut map = IndexMap::with_capacity(mounts.len()); + + for mount in mounts { + if let Err(e) = Identifier::new(mount.namespace.clone().into()) { + errors.push(ValidationError::IdentifierError { error: e }); + } + + if mount.namespace.len() > 63 { + errors.push(ValidationError::NamespaceTooLong { + namespace: mount.namespace.clone().into(), + len: mount.namespace.len(), + }); + } + + if map.contains_key(&mount.namespace) { + errors.push(ValidationError::DuplicateName { name: mount.namespace.into() }); + } else { + match validate(mount.module) { + Ok(def) => { + map.insert(mount.namespace, def); + } + Err(e) => errors.extend(e.into_iter()), + } + } + } + + ValidationErrors::add_extra_errors(Ok(map), errors) +} + +/// Check that no two modules in the mount tree claim the same lifecycle reducer. +/// +/// The host assigns exactly one reducer per lifecycle slot; if both the consumer +/// and a mounted submodule (or two sibling mounts) declare `__init__` (etc.), the +/// module must be rejected at publish time. +fn validate_no_lifecycle_conflicts( + root_lifecycles: &EnumMap>, + mounts: &IndexMap, +) -> Result<()> { + let mut claimed_by: EnumMap> = EnumMap::default(); + let mut errors: Vec = vec![]; + + for (lifecycle, opt_id) in root_lifecycles { + if opt_id.is_some() { + claimed_by[lifecycle] = Some("".to_string()); + } + } + + collect_lifecycle_conflicts(mounts, "", &mut claimed_by, &mut errors); + + ValidationErrors::add_extra_errors(Ok(()), errors) +} + +fn collect_lifecycle_conflicts( + mounts: &IndexMap, + parent_path: &str, + claimed_by: &mut EnumMap>, + errors: &mut Vec, +) { + for (ns, def) in mounts { + let path = if parent_path.is_empty() { + ns.clone() + } else { + format!("{parent_path}::{ns}") + }; + + for (lifecycle, opt_id) in def.lifecycle_reducers_map() { + if opt_id.is_some() { + match &claimed_by[lifecycle] { + Some(prior) => errors.push(ValidationError::ConflictingMountLifecycle { + lifecycle, + first: prior.clone(), + second: path.clone(), + }), + None => claimed_by[lifecycle] = Some(path.clone()), + } + } + } + + collect_lifecycle_conflicts(def.mounts(), &path, claimed_by, errors); + } +} + /// Change the visibility of scheduled functions and lifecycle reducers to Internal. /// fn change_scheduled_functions_and_lifetimes_visibility( @@ -1094,12 +1192,16 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, MethodOrAny, RawModuleDefV10Builder}; + use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, MethodOrAny, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, + RawModuleMountV10, + }; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::http::Method as HttpMethod; use spacetimedb_lib::ScheduleAt; use spacetimedb_primitives::{ColId, ColList, ColSet}; + use spacetimedb_sats::raw_identifier::RawIdentifier; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, ProductType, SumValue}; use v9::{Lifecycle, TableAccess, TableType}; @@ -1474,6 +1576,66 @@ 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![RawModuleMountV10 { + namespace: "authlib".to_string(), + module: mounted_builder.finish(), + }])], + }; + + let def: ModuleDef = raw.try_into().expect("mounted module should validate"); + let mounts = def.mounts(); + + assert_eq!(mounts.len(), 1); + let mounted = mounts.get("authlib").expect("authlib mount should exist"); + assert!(mounted.table(&expect_identifier("sessions")).is_some()); + } + + #[test] + fn invalid_mount_namespace() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "".to_string(), + module: RawModuleDefV10::default(), + }])], + }; + + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::IdentifierError { error } => { + error == &IdentifierError::Empty {} + }); + } + + #[test] + fn duplicate_mount_namespace() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "authlib".to_string(), + module: RawModuleDefV10::default(), + }, + RawModuleMountV10 { + namespace: "authlib".to_string(), + module: RawModuleDefV10::default(), + }, + ])], + }; + + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::DuplicateName { name } => { + name == &RawIdentifier::from("authlib") + }); + } + #[test] fn invalid_unique_constraint_column_ref() { let mut builder = RawModuleDefV10Builder::new(); @@ -2516,4 +2678,118 @@ mod tests { assert_eq!(view.return_columns[0].view_name, id("Level2Person")); assert_eq!(view.param_columns[0].view_name, id("Level2Person")); } + + #[test] + fn namespace_exactly_63_chars_is_ok() { + let namespace = "a".repeat(63); + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace, + module: RawModuleDefV10::default(), + }])], + }; + let result: Result = raw.try_into(); + assert!(result.is_ok(), "63-char namespace should be valid"); + } + + #[test] + fn namespace_64_chars_is_rejected() { + let namespace = "a".repeat(64); + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: namespace.clone(), + module: RawModuleDefV10::default(), + }])], + }; + let expected_ns = RawIdentifier::from(namespace.clone()); + let result: Result = raw.try_into(); + expect_error_matching!(result, ValidationError::NamespaceTooLong { namespace: ns, len } => { + ns == &expected_ns && len == &64usize + }); + } + + fn make_module_with_lifecycle(lifecycle: Lifecycle) -> RawModuleDefV10 { + let mut b = RawModuleDefV10Builder::new(); + b.add_lifecycle_reducer(lifecycle, "lifecycle_fn", ProductType::unit()); + b.finish() + } + + #[test] + fn consumer_and_mount_same_lifecycle_is_rejected() { + // Build the consumer's sections using the builder, then add a Mounts section. + let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); + let mut sections = consumer_raw.sections; + sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }])); + + let result: Result = RawModuleDefV10 { sections }.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::Init && first == "" && second == "auth" + }); + } + + #[test] + fn two_sibling_mounts_same_lifecycle_is_rejected() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + RawModuleMountV10 { + namespace: "payments".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + ])], + }; + + let result: Result = raw.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::OnConnect && first == "auth" && second == "payments" + }); + } + + #[test] + fn different_lifecycles_across_mounts_is_ok() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }, + RawModuleMountV10 { + namespace: "payments".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + ])], + }; + + let result: Result = raw.try_into(); + assert!(result.is_ok(), "different lifecycles across mounts should be valid"); + } + + #[test] + fn nested_mount_conflicts_with_root_lifecycle() { + // consumer → auth → baz: consumer claims Init, baz also claims Init. + let auth = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "baz".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }])], + }; + + let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); + let mut sections = consumer_raw.sections; + sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth, + }])); + + let result: Result = RawModuleDefV10 { sections }.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::Init && first == "" && second == "auth::baz" + }); + } } diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index 618f8e3c9c4..7a65913291c 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -168,6 +168,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { http_handlers: IndexMap::new(), http_routes: Vec::new(), raw_module_def_version: RawModuleDefVersion::V9OrEarlier, + mounts: IndexMap::new(), }) } diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 0bab5445d92..19c0af5afcf 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -176,6 +176,19 @@ pub enum ValidationError { ok_type: PrettyAlgebraicType, err_type: PrettyAlgebraicType, }, + #[error( + "lifecycle event {lifecycle:?} is claimed by used `{first}` and `{second}`; \ + only one module in the dependency tree may declare each lifecycle" + )] + ConflictingMountLifecycle { + lifecycle: Lifecycle, + /// Namespace path of the first claimant + first: String, + /// Namespace path of the second claimant + second: String, + }, + #[error("mount namespace `{namespace}` is {len} characters, which exceeds the 63-character limit")] + NamespaceTooLong { namespace: RawIdentifier, len: usize }, } /// A wrapper around an `AlgebraicType` that implements `fmt::Display`. diff --git a/crates/schema/src/table_name.rs b/crates/schema/src/table_name.rs index 3fe32ed70da..ffd2e79fcd1 100644 --- a/crates/schema/src/table_name.rs +++ b/crates/schema/src/table_name.rs @@ -5,20 +5,26 @@ use spacetimedb_sats::{impl_deserialize, impl_serialize, impl_st, raw_identifier /// The name of a table. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct TableName(Identifier); +pub struct TableName(RawIdentifier); -impl_st!([] TableName, ts => Identifier::make_type(ts)); +impl_st!([] TableName, ts => RawIdentifier::make_type(ts)); impl_serialize!([] TableName, (self, ser) => self.0.serialize(ser)); -impl_deserialize!([] TableName, de => Identifier::deserialize(de).map(Self)); +impl_deserialize!([] TableName, de => RawIdentifier::deserialize(de).map(Self)); impl TableName { + /// Construct from a validated identifier (all user-defined tables). pub fn new(id: Identifier) -> Self { - Self(id) + Self(id.into()) + } + + /// Construct from an arbitrary raw string (e.g. mounted tables whose names contain `.`). + pub fn new_raw(name: RawIdentifier) -> Self { + Self(name) } #[cfg(any(test, feature = "test"))] pub fn for_test(name: &str) -> Self { - Self(Identifier::for_test(name)) + Self(RawIdentifier::new(name)) } } @@ -38,13 +44,13 @@ impl AsRef for TableName { impl From for Identifier { fn from(id: TableName) -> Self { - id.0 + Identifier::new(id.0).expect("TableName contains '.' or other non-identifier chars; use RawIdentifier instead") } } impl From for RawIdentifier { fn from(id: TableName) -> Self { - Identifier::from(id).into() + id.0 } } From 593b29db67e69e9a5a305f420846d4a98385d585 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Tue, 9 Jun 2026 17:35:18 +0200 Subject: [PATCH 2/3] Format --- crates/codegen/src/typescript.rs | 23 +++++++-- crates/schema/src/def.rs | 68 +++++++++++++++------------ crates/schema/src/def/validate/v10.rs | 14 +++--- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 5e45c691358..d69f70f915d 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -81,7 +81,8 @@ impl Lang for TypeScript { writeln!(out, "export default __t.row({{"); out.indent(1); - write_object_type_builder_fields(module, out, "", &product_def.elements, table.primary_key, true, true).unwrap(); + write_object_type_builder_fields(module, out, "", &product_def.elements, table.primary_key, true, true) + .unwrap(); out.dedent(1); writeln!(out, "}});"); OutputFile { @@ -139,8 +140,16 @@ impl Lang for TypeScript { writeln!(out, "export const params = {{"); out.with_indent(|out| { - write_object_type_builder_fields(module, out, "", &procedure.params_for_generate.elements, None, true, false) - .unwrap() + write_object_type_builder_fields( + module, + out, + "", + &procedure.params_for_generate.elements, + None, + true, + false, + ) + .unwrap() }); writeln!(out, "}};"); @@ -630,7 +639,9 @@ fn define_body_for_product( writeln!(out, "}});"); } else { writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, name, elements, None, true, false).unwrap()); + out.with_indent(|out| { + write_object_type_builder_fields(module, out, name, elements, None, true, false).unwrap() + }); writeln!(out, "}});"); } writeln!(out, "export type {name} = __Infer;"); @@ -882,7 +893,9 @@ fn define_body_for_sum( (Identifier::for_test(pascal), ty.clone()) }) .collect(); - out.with_indent(|out| write_object_type_builder_fields(module, out, name, &pascal_variants, None, false, false).unwrap()); + out.with_indent(|out| { + write_object_type_builder_fields(module, out, name, &pascal_variants, None, false, false).unwrap() + }); writeln!(out, "}});"); writeln!(out, "export type {name} = __Infer;"); out.newline(); diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 4f64ccdd519..0a490cae1a2 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -49,9 +49,7 @@ use spacetimedb_primitives::{ ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, }; use spacetimedb_sats::raw_identifier::RawIdentifier; -use spacetimedb_sats::{ - AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace, -}; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; pub mod deserialize; pub mod error; @@ -294,7 +292,10 @@ impl ModuleDef { } /// Look up a constraint by its full namespaced name (e.g., `"lib.library_table_id_unique"`). - pub fn find_constraint_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &ConstraintDef)> { + pub fn find_constraint_by_full_name( + &self, + full_name: &str, + ) -> Option<(String, &ModuleDef, &TableDef, &ConstraintDef)> { for (prefix, owning, table) in self.all_tables_with_prefix() { for constraint in table.constraints.values() { if format!("{}{}", prefix, &*constraint.name) == full_name { @@ -347,7 +348,11 @@ impl ModuleDef { out } - fn collect_reducers_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ReducerDef)>) { + fn collect_reducers_with_prefix<'a>( + &'a self, + prefix: &str, + out: &mut Vec<(String, &'a ModuleDef, &'a ReducerDef)>, + ) { for reducer in self.reducers.values() { out.push((prefix.to_string(), self, reducer)); } @@ -367,7 +372,11 @@ impl ModuleDef { out } - fn collect_procedures_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ProcedureDef)>) { + fn collect_procedures_with_prefix<'a>( + &'a self, + prefix: &str, + out: &mut Vec<(String, &'a ModuleDef, &'a ProcedureDef)>, + ) { for procedure in self.procedures.values() { out.push((prefix.to_string(), self, procedure)); } @@ -537,10 +546,7 @@ impl ModuleDef { /// Like `reducer_by_name` but also returns the `ModuleDef` that owns the reducer. /// Use the returned `ModuleDef` (not `self`) when calling `arg_seed_for`, so that /// type-index references in the `ReducerDef` are resolved against the correct typespace. - pub fn reducer_by_name_with_module<'a>( - &'a self, - name: &str, - ) -> Option<(ReducerId, &'a ReducerDef, &'a ModuleDef)> { + pub fn reducer_by_name_with_module<'a>(&'a self, name: &str) -> Option<(ReducerId, &'a ReducerDef, &'a ModuleDef)> { match name.split_once('.') { None => self .reducers @@ -641,10 +647,7 @@ impl ModuleDef { /// /// A plain name searches this module's own views. A dot-qualified name routes to /// the matching mount and recurses. Returns the `ViewDef` and the owning `ModuleDef`. - pub fn view_by_name_with_module<'a>( - &'a self, - name: &str, - ) -> Option<(&'a ViewDef, &'a ModuleDef)> { + pub fn view_by_name_with_module<'a>(&'a self, name: &str) -> Option<(&'a ViewDef, &'a ModuleDef)> { match name.split_once('.') { None => self.views.get(name).map(|def| (def, self)), Some((namespace, rest)) => { @@ -705,14 +708,17 @@ impl ModuleDef { /// Total anonymous view count including all mounted submodules (depth-first sum). pub fn total_anon_view_count(&self) -> usize { - self.anon_view_count() - + self.mounts.values().map(|m| m.total_anon_view_count()).sum::() + self.anon_view_count() + self.mounts.values().map(|m| m.total_anon_view_count()).sum::() } /// Total non-anonymous view count including all mounted submodules (depth-first sum). pub fn total_non_anon_view_count(&self) -> usize { self.non_anon_view_count() - + self.mounts.values().map(|m| m.total_non_anon_view_count()).sum::() + + self + .mounts + .values() + .map(|m| m.total_non_anon_view_count()) + .sum::() } /// Convenience method to look up a procedure, possibly by a string. @@ -2579,9 +2585,7 @@ mod tests { #[test] fn mounted_reducer_ids_are_depth_first() { - use spacetimedb_lib::db::raw_def::v10::{ - RawModuleDefV10Builder, RawModuleDefV10Section, RawModuleMountV10, - }; + use spacetimedb_lib::db::raw_def::v10::{RawModuleDefV10Builder, RawModuleDefV10Section, RawModuleMountV10}; // baz library: 1 reducer let mut baz_builder = RawModuleDefV10Builder::new(); @@ -2591,20 +2595,24 @@ mod tests { let mut auth_builder = RawModuleDefV10Builder::new(); auth_builder.add_reducer("auth_verify", ProductType::unit()); let mut auth_raw = auth_builder.finish(); - auth_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { - namespace: "baz".to_string(), - module: baz_builder.finish(), - }])); + auth_raw + .sections + .push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "baz".to_string(), + module: baz_builder.finish(), + }])); // consumer: 2 own reducers, mounts auth let mut consumer_builder = RawModuleDefV10Builder::new(); consumer_builder.add_reducer("consumer_a", ProductType::unit()); consumer_builder.add_reducer("consumer_b", ProductType::unit()); let mut consumer_raw = consumer_builder.finish(); - consumer_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { - namespace: "auth".to_string(), - module: auth_raw, - }])); + consumer_raw + .sections + .push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth_raw, + }])); let def: ModuleDef = consumer_raw.try_into().expect("valid module"); @@ -2634,7 +2642,9 @@ mod tests { assert_eq!(&*rdef.name, "consumer_a"); // reducer_by_name routes qualified names to mounted reducers - let (id, rdef) = def.reducer_by_name("auth.auth_verify").expect("qualified name resolves"); + let (id, rdef) = def + .reducer_by_name("auth.auth_verify") + .expect("qualified name resolves"); assert_eq!(id, ReducerId(2)); assert_eq!(&*rdef.name, "auth_verify"); diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 310ba105035..1b8a219557e 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -301,12 +301,10 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let ((tables, types, reducers, procedures, views, (http_handlers, http_routes)), mounts) = ( - tables_types_reducers_procedures_views, - mounts, - ) - .combine_errors() - .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; + let ((tables, types, reducers, procedures, views, (http_handlers, http_routes)), mounts) = + (tables_types_reducers_procedures_views, mounts) + .combine_errors() + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; validate_no_lifecycle_conflicts(&lifecycle_reducers, &mounts) .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; @@ -352,7 +350,9 @@ fn validate_mounts(mounts: Vec) -> Result { From e64e7cd3c15e1567a88d8f4000b394f08c357339 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Tue, 9 Jun 2026 17:45:56 +0200 Subject: [PATCH 3/3] Disallow lifecycle functions in mounts --- crates/schema/src/def/validate/v10.rs | 154 +++++++------------------- crates/schema/src/error.rs | 12 +- 2 files changed, 44 insertions(+), 122 deletions(-) diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 1b8a219557e..52d5d66e250 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,4 +1,3 @@ -use enum_map::EnumMap; use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::bsatn::Deserializer; @@ -306,9 +305,6 @@ pub fn validate(def: RawModuleDefV10) -> Result { .combine_errors() .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; - validate_no_lifecycle_conflicts(&lifecycle_reducers, &mounts) - .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; - let typespace_for_generate = typespace_for_generate.finish(); Ok(ModuleDef { @@ -331,7 +327,8 @@ pub fn validate(def: RawModuleDefV10) -> Result { } /// Validate that each mount's namespace is a valid identifier of at most 63 characters, -/// and that no two mounts share the same namespace. +/// that no two mounts share the same namespace, and that no mount declares lifecycle reducers +/// (lifecycle reducers are only permitted in the root module). /// This function will inspect each sub-mount and recursively collect errors. fn validate_mounts(mounts: Vec) -> Result> { let mut errors = vec![]; @@ -356,6 +353,14 @@ fn validate_mounts(mounts: Vec) -> Result { + for (lifecycle, opt_id) in def.lifecycle_reducers_map() { + if opt_id.is_some() { + errors.push(ValidationError::LifecycleInComponent { + lifecycle, + namespace: mount.namespace.clone(), + }); + } + } map.insert(mount.namespace, def); } Err(e) => errors.extend(e.into_iter()), @@ -366,59 +371,6 @@ fn validate_mounts(mounts: Vec) -> Result>, - mounts: &IndexMap, -) -> Result<()> { - let mut claimed_by: EnumMap> = EnumMap::default(); - let mut errors: Vec = vec![]; - - for (lifecycle, opt_id) in root_lifecycles { - if opt_id.is_some() { - claimed_by[lifecycle] = Some("".to_string()); - } - } - - collect_lifecycle_conflicts(mounts, "", &mut claimed_by, &mut errors); - - ValidationErrors::add_extra_errors(Ok(()), errors) -} - -fn collect_lifecycle_conflicts( - mounts: &IndexMap, - parent_path: &str, - claimed_by: &mut EnumMap>, - errors: &mut Vec, -) { - for (ns, def) in mounts { - let path = if parent_path.is_empty() { - ns.clone() - } else { - format!("{parent_path}::{ns}") - }; - - for (lifecycle, opt_id) in def.lifecycle_reducers_map() { - if opt_id.is_some() { - match &claimed_by[lifecycle] { - Some(prior) => errors.push(ValidationError::ConflictingMountLifecycle { - lifecycle, - first: prior.clone(), - second: path.clone(), - }), - None => claimed_by[lifecycle] = Some(path.clone()), - } - } - } - - collect_lifecycle_conflicts(def.mounts(), &path, claimed_by, errors); - } -} - /// Change the visibility of scheduled functions and lifecycle reducers to Internal. /// fn change_scheduled_functions_and_lifetimes_visibility( @@ -2715,64 +2667,40 @@ mod tests { } #[test] - fn consumer_and_mount_same_lifecycle_is_rejected() { - // Build the consumer's sections using the builder, then add a Mounts section. - let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); - let mut sections = consumer_raw.sections; - sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { - namespace: "auth".to_string(), - module: make_module_with_lifecycle(Lifecycle::Init), - }])); - - let result: Result = RawModuleDefV10 { sections }.try_into(); - expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { - lifecycle == &Lifecycle::Init && first == "" && second == "auth" - }); - } - - #[test] - fn two_sibling_mounts_same_lifecycle_is_rejected() { + fn lifecycle_in_mount_is_rejected() { let raw = RawModuleDefV10 { - sections: vec![RawModuleDefV10Section::Mounts(vec![ - RawModuleMountV10 { - namespace: "auth".to_string(), - module: make_module_with_lifecycle(Lifecycle::OnConnect), - }, - RawModuleMountV10 { - namespace: "payments".to_string(), - module: make_module_with_lifecycle(Lifecycle::OnConnect), - }, - ])], + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }])], }; let result: Result = raw.try_into(); - expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { - lifecycle == &Lifecycle::OnConnect && first == "auth" && second == "payments" + expect_error_matching!(result, ValidationError::LifecycleInComponent { lifecycle, namespace } => { + lifecycle == &Lifecycle::Init && namespace == "auth" }); } #[test] - fn different_lifecycles_across_mounts_is_ok() { - let raw = RawModuleDefV10 { - sections: vec![RawModuleDefV10Section::Mounts(vec![ - RawModuleMountV10 { - namespace: "auth".to_string(), - module: make_module_with_lifecycle(Lifecycle::Init), - }, - RawModuleMountV10 { - namespace: "payments".to_string(), - module: make_module_with_lifecycle(Lifecycle::OnConnect), - }, - ])], - }; + fn lifecycle_in_root_with_mount_is_ok() { + // Root declares Init; the mount has no lifecycle — this is valid. + let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); + let mut sections = consumer_raw.sections; + sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: RawModuleDefV10::default(), + }])); - let result: Result = raw.try_into(); - assert!(result.is_ok(), "different lifecycles across mounts should be valid"); + let result: Result = RawModuleDefV10 { sections }.try_into(); + assert!( + result.is_ok(), + "lifecycle in root with a lifecycle-free mount should be valid" + ); } #[test] - fn nested_mount_conflicts_with_root_lifecycle() { - // consumer → auth → baz: consumer claims Init, baz also claims Init. + fn lifecycle_in_nested_mount_is_rejected() { + // Root mounts auth; auth mounts baz; baz declares a lifecycle. Should be rejected. let auth = RawModuleDefV10 { sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { namespace: "baz".to_string(), @@ -2780,16 +2708,16 @@ mod tests { }])], }; - let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); - let mut sections = consumer_raw.sections; - sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { - namespace: "auth".to_string(), - module: auth, - }])); + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth, + }])], + }; - let result: Result = RawModuleDefV10 { sections }.try_into(); - expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { - lifecycle == &Lifecycle::Init && first == "" && second == "auth::baz" + let result: Result = raw.try_into(); + expect_error_matching!(result, ValidationError::LifecycleInComponent { lifecycle, namespace } => { + lifecycle == &Lifecycle::Init && namespace == "baz" }); } } diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 19c0af5afcf..b428e26c009 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -177,16 +177,10 @@ pub enum ValidationError { err_type: PrettyAlgebraicType, }, #[error( - "lifecycle event {lifecycle:?} is claimed by used `{first}` and `{second}`; \ - only one module in the dependency tree may declare each lifecycle" + "lifecycle event {lifecycle:?} is not permitted in component under namespace `{namespace}`; \ + lifecycle reducers may only be declared in the root module" )] - ConflictingMountLifecycle { - lifecycle: Lifecycle, - /// Namespace path of the first claimant - first: String, - /// Namespace path of the second claimant - second: String, - }, + LifecycleInComponent { lifecycle: Lifecycle, namespace: String }, #[error("mount namespace `{namespace}` is {len} characters, which exceeds the 63-character limit")] NamespaceTooLong { namespace: RawIdentifier, len: usize }, }