From c8cac1da22430a94b83a086658e3b782d17ea468 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 1 Jun 2026 15:40:28 +0200 Subject: [PATCH 1/2] Add sub-module mounts support + codegen --- .../internal/autogen/RawModuleDef.g.h | 2 +- .../autogen/RawModuleDefV10Section.g.h | 3 +- .../internal/autogen/RawModuleMountV10.g.h | 37 + .../internal/autogen/RawReducerDefV9.g.h | 2 +- .../Autogen/RawModuleDefV10Section.g.cs | 3 +- .../Internal/Autogen/RawModuleMountV10.g.cs | 36 + .../src/lib/autogen/types.ts | 69 +- crates/bindings-typescript/src/lib/schema.ts | 39 +- crates/codegen/src/cpp.rs | 58 ++ crates/codegen/src/typescript.rs | 254 ++++++- .../src/host/wasm_common/module_host_actor.rs | 2 +- crates/lib/src/db/raw_def/v10.rs | 19 + crates/schema/src/def.rs | 654 +++++++++++++++++- crates/schema/src/def/validate/v10.rs | 289 +++++++- crates/schema/src/def/validate/v9.rs | 1 + crates/schema/src/error.rs | 13 + crates/schema/src/table_name.rs | 20 +- 17 files changed, 1420 insertions(+), 81 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h index c7f144eb07d..294169acc3b 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h @@ -12,9 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" +#include "RawModuleDefV10.g.h" #include "RawModuleDefV8.g.h" #include "RawModuleDefV9.g.h" -#include "RawModuleDefV10.g.h" namespace SpacetimeDB::Internal { 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..e614d8b5225 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -25,8 +25,9 @@ #include "RawTableDefV10.g.h" #include "RawRowLevelSecurityDefV9.g.h" #include "RawHttpRouteDefV10.g.h" +#include "RawModuleMountV10.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) } // 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..26ac7c9a690 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h @@ -0,0 +1,37 @@ +// 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" + +// Forward declaration breaks the circular include chain: +// RawModuleMountV10 -> RawModuleDefV10 -> RawModuleDefV10Section -> RawModuleMountV10 +namespace SpacetimeDB::Internal { struct RawModuleDefV10; } + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { + std::string namespace_; // renamed: 'namespace' is a C++ keyword + std::shared_ptr module; // shared_ptr breaks infinite-size recursion + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, namespace_); + if (module) ::SpacetimeDB::bsatn::serialize(writer, *module); + } + bool operator==(const RawModuleMountV10& o) const noexcept { + if (namespace_ != o.namespace_) return false; + if (module && o.module) return *module == *o.module; + return !module && !o.module; + } + bool operator!=(const RawModuleMountV10& o) const noexcept { return !(*this == o); } +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h index 8121773a40d..964ed98df12 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h @@ -12,8 +12,8 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "Lifecycle.g.h" #include "ProductType.g.h" +#include "Lifecycle.g.h" 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 6706fb278f0..7cedeb59070 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -21,6 +21,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, ExplicitNames ExplicitNames, System.Collections.Generic.List HttpHandlers, - System.Collections.Generic.List HttpRoutes + System.Collections.Generic.List HttpRoutes, + 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 c0855af29bf..e0a09e75ae7 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -339,56 +339,77 @@ export const RawModuleDef = __t.enum('RawModuleDef', { }); export type RawModuleDef = __Infer; -export const RawModuleDefV10 = __t.object('RawModuleDefV10', { - get sections() { +export type RawModuleDefV10 = { + sections: RawModuleDefV10Section[]; +}; + +export const RawModuleDefV10: any = __t.object('RawModuleDefV10', { + get sections(): any { return __t.array(RawModuleDefV10Section); }, }); -export type RawModuleDefV10 = __Infer; // The tagged union or sum type for the algebraic type `RawModuleDefV10Section`. -export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { - get Typespace() { +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: 'HttpHandlers'; value: RawHttpHandlerDefV10[] } + | { tag: 'HttpRoutes'; value: RawHttpRouteDefV10[] } + | { tag: 'Mounts'; value: RawModuleMountV10[] }; + +export const RawModuleDefV10Section: any = __t.enum('RawModuleDefV10Section', { + get Typespace(): any { return Typespace; }, - get Types() { + get Types(): any { return __t.array(RawTypeDefV10); }, - get Tables() { + get Tables(): any { return __t.array(RawTableDefV10); }, - get Reducers() { + get Reducers(): any { return __t.array(RawReducerDefV10); }, - get Procedures() { + get Procedures(): any { return __t.array(RawProcedureDefV10); }, - get Views() { + get Views(): any { return __t.array(RawViewDefV10); }, - get Schedules() { + get Schedules(): any { return __t.array(RawScheduleDefV10); }, - get LifeCycleReducers() { + get LifeCycleReducers(): any { return __t.array(RawLifeCycleReducerDefV10); }, - get RowLevelSecurity() { + get RowLevelSecurity(): any { return __t.array(RawRowLevelSecurityDefV9); }, - get CaseConversionPolicy() { + get CaseConversionPolicy(): any { return CaseConversionPolicy; }, - get ExplicitNames() { + get ExplicitNames(): any { return ExplicitNames; }, - get HttpHandlers() { + get HttpHandlers(): any { return __t.array(RawHttpHandlerDefV10); }, - get HttpRoutes() { + get HttpRoutes(): any { return __t.array(RawHttpRouteDefV10); }, + get Mounts(): any { + return __t.array(RawModuleMountV10); + }, }); -export type RawModuleDefV10Section = __Infer; export const RawModuleDefV8 = __t.object('RawModuleDefV8', { get typespace() { @@ -428,6 +449,18 @@ export const RawModuleDefV9 = __t.object('RawModuleDefV9', { }); export type RawModuleDefV9 = __Infer; +export type RawModuleMountV10 = { + namespace: string; + module: RawModuleDefV10; +}; + +export const RawModuleMountV10: any = __t.object('RawModuleMountV10', { + namespace: __t.string(), + get module(): any { + return RawModuleDefV10; + }, +}); + 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 ab480c93db5..f92baafd377 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -7,10 +7,22 @@ import { } from './algebraic_type'; import type { CaseConversionPolicy, + ExplicitNames, + RawHttpHandlerDefV10, + RawHttpRouteDefV10, + RawLifeCycleReducerDefV10, + RawModuleMountV10, RawModuleDefV10, RawModuleDefV10Section, + RawProcedureDefV10, + RawReducerDefV10, + RawRowLevelSecurityDefV9, + RawScheduleDefV10, RawScopedTypeNameV10, RawTableDefV10, + RawTypeDefV10, + RawViewDefV10, + Typespace, } from './autogen/types'; import type { UntypedIndex } from './indexes'; import type { UntypedTableDef } from './table'; @@ -42,6 +54,7 @@ export type TableNamesOf = Values< */ export type UntypedSchemaDef = { tables: Record; + namespaces?: Record; }; /** @@ -174,7 +187,20 @@ 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[]; + httpHandlers: RawHttpHandlerDefV10[]; + httpRoutes: RawHttpRouteDefV10[]; + rowLevelSecurity: RawRowLevelSecurityDefV9[]; + caseConversionPolicy: CaseConversionPolicy; + explicitNames: ExplicitNames; + mounts: RawModuleMountV10[]; }; type Section = RawModuleDefV10Section; @@ -201,6 +227,7 @@ export class ModuleContext { explicitNames: { entries: [], }, + mounts: [], }; get moduleDef(): ModuleDef { @@ -259,9 +286,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/cpp.rs b/crates/codegen/src/cpp.rs index 9ddbeae8bb0..c7e07e5e3fc 100644 --- a/crates/codegen/src/cpp.rs +++ b/crates/codegen/src/cpp.rs @@ -231,6 +231,53 @@ impl<'opts> Cpp<'opts> { writeln!(output, "}};").unwrap(); } + fn generate_raw_module_mount_v10_special(&self) -> String { + // RawModuleMountV10 is special for two reasons: + // 1. Its `namespace` field is a C++ keyword, renamed to `namespace_`. + // 2. It contains `RawModuleDefV10` which creates a circular include chain: + // RawModuleMountV10 → RawModuleDefV10 → RawModuleDefV10Section → RawModuleMountV10 + // We break this with a forward declaration and shared_ptr (which only needs a declaration). + r#"// 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" + +// Forward declaration breaks the circular include chain: +// RawModuleMountV10 -> RawModuleDefV10 -> RawModuleDefV10Section -> RawModuleMountV10 +namespace SpacetimeDB::Internal { struct RawModuleDefV10; } + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { + std::string namespace_; // renamed: 'namespace' is a C++ keyword + std::shared_ptr module; // shared_ptr breaks infinite-size recursion + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, namespace_); + if (module) ::SpacetimeDB::bsatn::serialize(writer, *module); + } + bool operator==(const RawModuleMountV10& o) const noexcept { + if (namespace_ != o.namespace_) return false; + if (module && o.module) return *module == *o.module; + return !module && !o.module; + } + bool operator!=(const RawModuleMountV10& o) const noexcept { return !(*this == o); } +}; +} // namespace SpacetimeDB::Internal +"# + .to_string() + } + // Generate minimal sum type (TaggedEnum only) fn write_sum_type(&self, output: &mut String, module: &ModuleDef, type_name: &str, sum: &SumTypeDef) { // Special case: Generate proper tagged enum for RawIndexAlgorithm with data variants @@ -529,6 +576,17 @@ impl Lang for Cpp<'_> { }]; } + // Special handling for RawModuleMountV10: + // (1) its `namespace` field is a C++ keyword; (2) its `module` field creates a + // circular include chain through RawModuleDefV10 → RawModuleDefV10Section. + // We break both with a forward declaration and shared_ptr. + if name.to_string() == "RawModuleMountV10" { + return vec![OutputFile { + filename: format!("{name}.g.h"), + code: self.generate_raw_module_mount_v10_special(), + }]; + } + self.write_standard_includes(&mut output); // Add includes for dependencies diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 28bc1fb4c91..114246cf443 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -475,6 +475,75 @@ fn generate_procedures_file(module: &ModuleDef, options: &CodegenOptions) -> Out } } +/// Collect all `AlgebraicTypeRef`s directly referenced by a type def. +fn direct_refs_of_def(def: &AlgebraicTypeDef) -> Vec { + fn refs_of_use(ty: &AlgebraicTypeUse, out: &mut Vec) { + match ty { + AlgebraicTypeUse::Ref(r) => out.push(*r), + AlgebraicTypeUse::Array(e) | AlgebraicTypeUse::Option(e) => refs_of_use(e, out), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + refs_of_use(ok_ty, out); + refs_of_use(err_ty, out); + } + _ => {} + } + } + let mut refs = Vec::new(); + match def { + AlgebraicTypeDef::Product(p) => { + for (_, ty) in &p.elements { + refs_of_use(ty, &mut refs); + } + } + AlgebraicTypeDef::Sum(s) => { + for (_, ty) in &s.variants { + refs_of_use(ty, &mut refs); + } + } + AlgebraicTypeDef::PlainEnum(_) => {} + } + refs +} + +/// BFS to find all type refs reachable from `start` in the type-dependency graph. +fn reachable_from( + typespace: &spacetimedb_schema::type_for_generate::TypespaceForGenerate, + start: AlgebraicTypeRef, +) -> BTreeSet { + let mut visited = BTreeSet::new(); + let mut stack = vec![start]; + while let Some(r) = stack.pop() { + if !visited.insert(r) { + continue; + } + if let Some(def) = typespace.get(r) { + for neighbor in direct_refs_of_def(def) { + stack.push(neighbor); + } + } + } + visited +} + +/// Get all strongly connected components within the provided ModuleDef types. +/// Used to compute circular dependencies within the provided ModuleDef. +fn algebraic_type_scc(module: &ModuleDef) -> BTreeSet { + let Some(at_ref) = iter_types(module) + .find(|ty| type_ref_name(module, ty.ty) == "AlgebraicType") + .map(|ty| ty.ty) + else { + return BTreeSet::new(); + }; + + let typespace = module.typespace_for_generate(); + let from_at = reachable_from(typespace, at_ref); + from_at + .iter() + .filter(|&&r| reachable_from(typespace, r).contains(&at_ref)) + .copied() + .collect() +} + fn generate_types_file(module: &ModuleDef) -> OutputFile { let mut output = CodeIndenter::new(String::new(), INDENT); let out = &mut output; @@ -486,6 +555,7 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { .reducers() .map(|reducer| reducer.accessor_name.deref().to_case(Case::Pascal)) .collect::>(); + let algebraic_scc = algebraic_type_scc(module); for ty in iter_types(module) { let type_name = collect_case(Case::Pascal, ty.accessor_name.name_segments()); @@ -493,9 +563,13 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { continue; } - match &module.typespace_for_generate()[ty.ty] { - AlgebraicTypeDef::Product(product) => define_body_for_product(module, out, &type_name, &product.elements), - AlgebraicTypeDef::Sum(sum) => define_body_for_sum(module, out, &type_name, &sum.variants), + let type_def = &module.typespace_for_generate()[ty.ty]; + let is_recursive = type_def.is_recursive() && !algebraic_scc.contains(&ty.ty); + match type_def { + AlgebraicTypeDef::Product(product) => { + define_body_for_product(module, out, &type_name, &product.elements, is_recursive) + } + AlgebraicTypeDef::Sum(sum) => define_body_for_sum(module, out, &type_name, &sum.variants, is_recursive), AlgebraicTypeDef::PlainEnum(plain_enum) => { let variants = plain_enum .variants @@ -503,7 +577,7 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { .cloned() .map(|var| (var, AlgebraicTypeUse::Unit)) .collect::>(); - define_body_for_sum(module, out, &type_name, &variants) + define_body_for_sum(module, out, &type_name, &variants, false) } } } @@ -514,6 +588,105 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { } } +/// Converts an `AlgebraicTypeUse` to a TypeScript type expression for use in explicit type aliases. +/// Used when generating `export type Foo = { ... }` for recursive types. +fn ts_type_use(module: &ModuleDef, ty: &AlgebraicTypeUse) -> String { + match ty { + AlgebraicTypeUse::String => "string".to_string(), + AlgebraicTypeUse::Primitive(PrimitiveType::Bool) => "boolean".to_string(), + AlgebraicTypeUse::Primitive( + PrimitiveType::I8 + | PrimitiveType::U8 + | PrimitiveType::I16 + | PrimitiveType::U16 + | PrimitiveType::I32 + | PrimitiveType::U32 + | PrimitiveType::F32 + | PrimitiveType::F64, + ) => "number".to_string(), + AlgebraicTypeUse::Primitive(_) => "bigint".to_string(), + AlgebraicTypeUse::Array(elem) if matches!(&**elem, AlgebraicTypeUse::Primitive(PrimitiveType::U8)) => { + "Uint8Array".to_string() + } + AlgebraicTypeUse::Array(elem) => format!("{}[]", ts_type_use(module, elem)), + AlgebraicTypeUse::Option(inner) => format!("{} | undefined", ts_type_use(module, inner)), + AlgebraicTypeUse::Ref(r) => type_ref_name(module, *r), + AlgebraicTypeUse::Unit => "Record".to_string(), + AlgebraicTypeUse::Never => "never".to_string(), + AlgebraicTypeUse::Identity => "__Identity".to_string(), + AlgebraicTypeUse::ConnectionId => "__ConnectionId".to_string(), + AlgebraicTypeUse::Timestamp => "__Timestamp".to_string(), + AlgebraicTypeUse::TimeDuration => "__TimeDuration".to_string(), + AlgebraicTypeUse::ScheduleAt => "__ScheduleAt".to_string(), + AlgebraicTypeUse::Uuid => "__Uuid".to_string(), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + format!( + "{{ ok: {} }} | {{ err: {} }}", + ts_type_use(module, ok_ty), + ts_type_use(module, err_ty) + ) + } + } +} + +/// Emits an explicit TypeScript type alias for a product type, used for recursive types. +/// e.g. `export type Foo = { bar: Bar; };` +fn write_ts_type_alias_for_product( + module: &ModuleDef, + out: &mut Indenter, + name: &str, + elements: &[(Identifier, AlgebraicTypeUse)], +) { + writeln!(out, "export type {name} = {{"); + out.indent(1); + for (ident, ty) in elements { + let field_name = ident.deref().to_case(Case::Camel); + writeln!(out, "{field_name}: {};", ts_type_use(module, ty)); + } + out.dedent(1); + writeln!(out, "}};"); + out.newline(); +} + +/// Emits an explicit TypeScript type alias for a sum type, used for recursive types. +/// e.g. `export type Foo = | { tag: 'Bar'; value: Bar } | { tag: 'Baz'; value: Baz };` +fn write_ts_type_alias_for_sum( + module: &ModuleDef, + out: &mut Indenter, + name: &str, + variants: &[(Identifier, AlgebraicTypeUse)], +) { + writeln!(out, "export type {name} ="); + out.indent(1); + for (i, (ident, ty)) in variants.iter().enumerate() { + let variant_name = ident.deref().to_case(Case::Pascal); + let is_last = i == variants.len() - 1; + let semicolon = if is_last { ";" } else { "" }; + writeln!( + out, + "| {{ tag: '{variant_name}'; value: {} }}{semicolon}", + ts_type_use(module, ty) + ); + } + out.dedent(1); + out.newline(); +} + +/// Emits a getter field with an explicit `: any` return-type annotation. +/// +/// Required for getters inside `const X: any = __t.object/enum(…)` declarations of +/// recursive types: without the annotation TypeScript emits TS7023 ("implicitly has +/// return type 'any' because it is referenced in its own return expressions"). +fn write_any_getter_field(module: &ModuleDef, out: &mut Indenter, name: &str, ty: &AlgebraicTypeUse) { + writeln!(out, "get {name}(): any {{"); + out.indent(1); + write!(out, "return "); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ";"); + out.dedent(1); + writeln!(out, "}},"); +} + fn print_index_imports(out: &mut Indenter) { // All library imports are prefixed with `__` to avoid // clashing with the names of user generated types. @@ -624,16 +797,38 @@ fn define_body_for_product( out: &mut Indenter, name: &str, elements: &[(Identifier, AlgebraicTypeUse)], + is_recursive: bool, ) { - write!(out, "export const {name} = __t.object(\"{name}\", {{"); - if elements.is_empty() { + if is_recursive { + // Emit an explicit TS type alias to break the circular type-inference chain + write_ts_type_alias_for_product(module, out, name, elements); + writeln!(out, "export const {name}: any = __t.object(\"{name}\", {{"); + if !elements.is_empty() { + out.with_indent(|out| { + for (ident, ty) in elements { + let field_name = ident.deref().to_case(Case::Camel); + if type_contains_ref(ty) { + write_any_getter_field(module, out, &field_name, ty); + } else { + write!(out, "{field_name}: "); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ","); + } + } + }); + } writeln!(out, "}});"); } else { - writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); - writeln!(out, "}});"); + write!(out, "export const {name} = __t.object(\"{name}\", {{"); + if elements.is_empty() { + writeln!(out, "}});"); + } else { + writeln!(out); + out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); + writeln!(out, "}});"); + } + writeln!(out, "export type {name} = __Infer;"); } - writeln!(out, "export type {name} = __Infer;"); out.newline(); } @@ -858,13 +1053,8 @@ fn define_body_for_sum( out: &mut Indenter, name: &str, variants: &[(Identifier, AlgebraicTypeUse)], + is_recursive: bool, ) { - writeln!(out, "// The tagged union or sum type for the algebraic type `{name}`."); - write!(out, "export const {name}"); - if name == "AlgebraicType" { - write!(out, ": __TypeBuilder<__AlgebraicTypeType, __AlgebraicTypeType>"); - } - writeln!(out, " = __t.enum(\"{name}\", {{"); // Convert variant names to PascalCase let pascal_variants: Vec<(Identifier, AlgebraicTypeUse)> = variants .iter() @@ -873,9 +1063,35 @@ 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()); - writeln!(out, "}});"); - writeln!(out, "export type {name} = __Infer;"); + + writeln!(out, "// The tagged union or sum type for the algebraic type `{name}`."); + if is_recursive { + write_ts_type_alias_for_sum(module, out, name, variants); + writeln!(out, "export const {name}: any = __t.enum(\"{name}\", {{"); + out.with_indent(|out| { + for (ident, ty) in &pascal_variants { + if type_contains_ref(ty) { + write_any_getter_field(module, out, ident.deref(), ty); + } else { + write!(out, "{}: ", ident.deref()); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ","); + } + } + }); + writeln!(out, "}});"); + } else { + write!(out, "export const {name}"); + if name == "AlgebraicType" { + write!(out, ": __TypeBuilder<__AlgebraicTypeType, __AlgebraicTypeType>"); + } + writeln!(out, " = __t.enum(\"{name}\", {{"); + out.with_indent(|out| { + write_object_type_builder_fields(module, out, &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 2fb5eab0492..e3000fbdddc 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -1579,7 +1579,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 47a4281e86a..969580cb7f0 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -95,6 +95,9 @@ pub enum RawModuleDefV10Section { /// HTTP route definitions. HttpRoutes(Vec), + + /// Mounted submodules, keyed by the namespace they are mounted under. + Mounts(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -121,6 +124,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)] @@ -540,6 +551,14 @@ pub struct RawViewDefV10 { } 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 baae44ed76f..6c306b7379d 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, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, + RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, + RawTableDefV10, RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, @@ -43,12 +43,15 @@ use spacetimedb_lib::db::raw_def::v9::{ RawScheduleDefV9, RawScopedTypeNameV9, RawSequenceDefV9, RawSql, RawTableDefV9, RawTypeDefV9, RawUniqueConstraintDataV9, RawViewDefV9, TableAccess, TableType, }; -use spacetimedb_lib::{ProductType, RawModuleDef}; +use spacetimedb_lib::RawModuleDef; 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, ArrayType, ProductType, ProductTypeElement, SumType, + SumTypeVariant, Typespace, +}; pub mod deserialize; pub mod error; @@ -163,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)] @@ -179,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() @@ -204,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. @@ -363,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. @@ -381,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. @@ -398,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. @@ -411,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)) @@ -506,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 @@ -524,21 +915,154 @@ impl From for RawModuleDefV9 { }) .collect(); + // Flatten mounted modules into the root table/reducer/procedure lists and typespace. + // Each mount's typespace is appended to the merged types with all AlgebraicTypeRef + // indices shifted by the current length, keeping internal references valid. + let root_anon_view_count = views.values().filter(|v| v.is_anonymous).count() as u32; + let root_non_anon_view_count = views.values().filter(|v| !v.is_anonymous).count() as u32; + let mut flat_tables: Vec = to_raw(tables); + let mut flat_reducers: Vec = reducers.into_iter().map(|(_, def)| def.into()).collect(); + let mut flat_misc: Vec = column_defaults + .into_iter() + .chain(procedures.into_iter().map(|(_, def)| def.into())) + .chain(views.into_iter().map(|(_, def)| def.into())) + .collect(); + let mut merged_types = typespace.types; + let mut anon_view_offset = root_anon_view_count; + let mut view_offset = root_non_anon_view_count; + collect_v9_mounts( + &mounts, + "", + &mut flat_tables, + &mut flat_reducers, + &mut flat_misc, + &mut merged_types, + &mut anon_view_offset, + &mut view_offset, + ); + RawModuleDefV9 { - tables: to_raw(tables), - reducers: reducers.into_iter().map(|(_, def)| def.into()).collect(), + tables: flat_tables, + reducers: flat_reducers, types: to_raw(types), - misc_exports: column_defaults - .into_iter() - .chain(procedures.into_iter().map(|(_, def)| def.into())) - .chain(views.into_iter().map(|(_, def)| def.into())) - .collect(), - typespace, + misc_exports: flat_misc, + typespace: Typespace::new(merged_types), row_level_security: row_level_security_raw.into_iter().map(|(_, def)| def).collect(), } } } +/// Recursively flatten mounted `ModuleDef`s into `flat_tables`, `flat_reducers`, +/// `flat_misc`, and `merged_types`. +/// +/// Tables are emitted with dot-namespaced names (e.g. `"lib.library_table"`). +/// Reducers, procedures, and views are emitted with slash/dot-namespaced names. +/// View fn_ptrs are shifted by `anon_view_offset` / `view_offset` so that the +/// TypeScript WASM runtime's flat dispatch arrays remain consistent. +/// Each mount's typespace types are appended with all `AlgebraicTypeRef` indices shifted +/// by the current length of `merged_types`, keeping internal references valid. +fn collect_v9_mounts( + mounts: &IndexMap, + parent_prefix: &str, + flat_tables: &mut Vec, + flat_reducers: &mut Vec, + flat_misc: &mut Vec, + merged_types: &mut Vec, + anon_view_offset: &mut u32, + view_offset: &mut u32, +) { + for (ns, mount) in mounts { + let prefix = format!("{parent_prefix}{ns}."); + let fn_prefix = prefix.replace('.', "/"); + let type_offset = merged_types.len() as u32; + for ty in &mount.typespace().types { + merged_types.push(shift_type_refs(ty.clone(), type_offset)); + } + for table_def in mount.tables() { + let mut raw: RawTableDefV9 = table_def.clone().into(); + raw.name = format!("{}{}", prefix, table_def.accessor_name).into(); + raw.product_type_ref = AlgebraicTypeRef(table_def.product_type_ref.0 + type_offset); + flat_tables.push(raw); + } + for reducer_def in mount.reducers() { + let mut raw: RawReducerDefV9 = reducer_def.clone().into(); + raw.name = format!("{}{}", fn_prefix, reducer_def.name).into(); + flat_reducers.push(raw); + } + for procedure_def in mount.procedures() { + let mut raw: RawProcedureDefV9 = procedure_def.clone().into(); + raw.name = format!("{}{}", fn_prefix, procedure_def.name).into(); + flat_misc.push(RawMiscModuleExportV9::Procedure(raw)); + } + let mount_anon = mount.anon_view_count() as u32; + let mount_non_anon = mount.non_anon_view_count() as u32; + for view_def in mount.views() { + let mut raw: RawViewDefV9 = view_def.clone().into(); + raw.name = format!("{}{}", prefix, view_def.accessor_name).into(); + let local_offset = if view_def.is_anonymous { + *anon_view_offset + } else { + *view_offset + }; + raw.index = view_def.fn_ptr.0 + local_offset; + // Shift all AlgebraicTypeRefs in the return type and params to the merged typespace. + raw.return_type = shift_type_refs(raw.return_type, type_offset); + raw.params = ProductType::new( + raw.params + .elements + .iter() + .map(|e| ProductTypeElement { + name: e.name.clone(), + algebraic_type: shift_type_refs(e.algebraic_type.clone(), type_offset), + }) + .collect::>(), + ); + flat_misc.push(RawMiscModuleExportV9::View(raw)); + } + collect_v9_mounts( + mount.mounts(), + &prefix, + flat_tables, + flat_reducers, + flat_misc, + merged_types, + anon_view_offset, + view_offset, + ); + *anon_view_offset += mount_anon; + *view_offset += mount_non_anon; + } +} + +/// Recursively shift all `AlgebraicTypeRef` indices in `ty` by `offset`. +fn shift_type_refs(ty: AlgebraicType, offset: u32) -> AlgebraicType { + match ty { + AlgebraicType::Ref(r) => AlgebraicType::Ref(AlgebraicTypeRef(r.0 + offset)), + AlgebraicType::Product(p) => AlgebraicType::Product(ProductType::new( + p.elements + .iter() + .map(|e| ProductTypeElement { + name: e.name.clone(), + algebraic_type: shift_type_refs(e.algebraic_type.clone(), offset), + }) + .collect::>(), + )), + AlgebraicType::Sum(s) => AlgebraicType::Sum(SumType::new( + s.variants + .iter() + .map(|v| SumTypeVariant { + name: v.name.clone(), + algebraic_type: shift_type_refs(v.algebraic_type.clone(), offset), + }) + .collect::>(), + )), + AlgebraicType::Array(a) => AlgebraicType::Array(ArrayType { + elem_ty: Box::new(shift_type_refs(*a.elem_ty, offset)), + }), + other => other, + } +} + impl TryFrom for ModuleDef { type Error = ValidationErrors; @@ -564,6 +1088,7 @@ impl From for RawModuleDefV10 { http_handlers, http_routes, raw_module_def_version: _, + mounts, } = val; let mut sections = Vec::new(); @@ -698,6 +1223,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 } } } @@ -2143,4 +2679,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 5ea6370f2d0..e686839ead1 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::{ @@ -82,6 +85,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(); @@ -290,14 +299,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.and_then(validate_mount_names_are_unique), + ) + .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(); @@ -316,9 +326,92 @@ pub fn validate(def: RawModuleDefV10) -> Result { http_handlers, http_routes, raw_module_def_version: RawModuleDefVersion::V10, + mounts, }) } +fn validate_mount(mount: RawModuleMountV10) -> Result<(String, ModuleDef)> { + Identifier::new(mount.namespace.clone().into()) + .map_err(|error| ValidationErrors::from(ValidationError::IdentifierError { error }))?; + + if mount.namespace.len() > 63 { + return Err(ValidationErrors::from(ValidationError::NamespaceTooLong { + namespace: mount.namespace.clone().into(), + len: mount.namespace.len(), + })); + } + + Ok((mount.namespace, validate(mount.module)?)) +} + +fn validate_mount_names_are_unique(mounts: Vec<(String, ModuleDef)>) -> Result> { + let mut errors = vec![]; + let mut map = IndexMap::with_capacity(mounts.len()); + + for (namespace, def) in mounts { + if map.contains_key(&namespace) { + errors.push(ValidationError::DuplicateName { name: namespace.into() }); + } else { + map.insert(namespace, def); + } + } + + 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( @@ -1004,12 +1097,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}; @@ -1384,6 +1481,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(); @@ -2426,4 +2583,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 33abb0c1866..369f8090f42 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -165,6 +165,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 f9b867edb79f4c56f495dbb83919f4c3bf3a6dbf Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Thu, 4 Jun 2026 11:03:32 +0200 Subject: [PATCH 2/2] Update crates/codegen/src/cpp.rs Co-authored-by: Jason Larabie Signed-off-by: Alessandro Asoni --- crates/codegen/src/cpp.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/codegen/src/cpp.rs b/crates/codegen/src/cpp.rs index c7e07e5e3fc..5741b319e41 100644 --- a/crates/codegen/src/cpp.rs +++ b/crates/codegen/src/cpp.rs @@ -267,9 +267,7 @@ SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { if (module) ::SpacetimeDB::bsatn::serialize(writer, *module); } bool operator==(const RawModuleMountV10& o) const noexcept { - if (namespace_ != o.namespace_) return false; - if (module && o.module) return *module == *o.module; - return !module && !o.module; + return namespace_ == o.namespace_ && module == o.module; } bool operator!=(const RawModuleMountV10& o) const noexcept { return !(*this == o); } };